fix: keep initialize on legacy protocol versions (#1228)

Claude-Session: https://claude.ai/code/session_01EPcBfiUWJJueJn5cve1sjp
This commit is contained in:
Dale Seo 2026-09-01 07:38:57 +09:00 • committed by GitHub
commit ad9832ec21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 369 additions and 120 deletions

View file

@ -2,10 +2,14 @@
#![cfg(feature = "client")]
use rmcp::{
ClientHandler, ServerHandler, ServiceExt,
ClientHandler, RoleClient, RoleServer, ServerHandler,
handler::server::router::{prompt::PromptRouter, tool::ToolRouter},
model::{CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion},
prompt_handler, tool_handler,
model::{
CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion, ServerInfo,
},
prompt_handler,
service::serve_directly,
tool_handler,
};
#[derive(Debug, Clone)]
@ -40,22 +44,33 @@ impl ClientHandler for VersionedClient {
}
}
/// Wires the pair up directly on `protocol_version`. `2026-07-28` removed the
/// `initialize` handshake, so a peer on that revision is reached the way the
/// discover lifecycle leaves one: with the version already agreed.
async fn list_results(protocol_version: ProtocolVersion) -> (ListToolsResult, ListPromptsResult) {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let client_handler = VersionedClient {
protocol_version: protocol_version.clone(),
};
let mut server_peer_info = ServerInfo::default();
server_peer_info.protocol_version = protocol_version;
let server = serve_directly::<RoleServer, _, _, _, _>(
CacheHintServer::new(),
server_transport,
Some(client_handler.get_info()),
);
let server_handle = tokio::spawn(async move {
CacheHintServer::new()
.serve(server_transport)
.await?
.waiting()
.await?;
server.waiting().await?;
anyhow::Ok(())
});
let client = VersionedClient { protocol_version }
.serve(client_transport)
.await
.expect("client should connect");
let client = serve_directly::<RoleClient, _, _, _, _>(
client_handler,
client_transport,
Some(server_peer_info.into()),
);
let tools = client
.list_tools(None)
.await

View file

@ -1,6 +1,7 @@
//! Tests for protocol version negotiation in the default ServerHandler::initialize impl.
//!
//! Known versions are echoed back; unknown versions fall back to LATEST.
//! Handshake versions are echoed back; every other version falls back to one
//! the server can serve over `initialize`.
#![cfg(not(feature = "local"))]
#![cfg(feature = "client")]
@ -8,8 +9,11 @@ use std::borrow::Cow;
use rmcp::{
ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt,
model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo},
service::RequestContext,
model::{
ClientInfo, ErrorCode, InitializeRequestParams, InitializeResult, ProtocolVersion,
ServerInfo,
},
service::{ClientInitializeError, RequestContext},
};
#[derive(Debug, Clone, Default)]
@ -21,9 +25,10 @@ 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] = &[
/// 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.
const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[
ProtocolVersion::V_2024_11_05,
ProtocolVersion::V_2025_03_26,
ProtocolVersion::V_2025_06_18,
@ -39,7 +44,25 @@ impl ServerHandler for NarrowedServer {
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(NARROWED_VERSIONS)
Cow::Borrowed(HANDSHAKE_VERSIONS)
}
}
/// Supports only revisions that have no `initialize` handshake at all.
#[derive(Debug, Clone, Default)]
struct ModernOnlyServer;
const MODERN_ONLY_VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2026_07_28];
impl ServerHandler for ModernOnlyServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.protocol_version = ProtocolVersion::V_2026_07_28;
info
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(MODERN_ONLY_VERSIONS)
}
}
@ -55,7 +78,7 @@ impl ServerHandler for NarrowedOverridingServer {
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(NARROWED_VERSIONS)
Cow::Borrowed(HANDSHAKE_VERSIONS)
}
async fn initialize(
@ -88,23 +111,28 @@ async fn negotiated_version_with<S: ServerHandler>(
server: S,
client_version: ProtocolVersion,
) -> ProtocolVersion {
negotiate_with(server, client_version)
.await
.expect("client should connect")
}
async fn negotiate_with<S: ServerHandler>(
server: S,
client_version: ProtocolVersion,
) -> Result<ProtocolVersion, ClientInitializeError> {
let (server_transport, client_transport) = tokio::io::duplex(4096);
tokio::spawn(async move {
let _ = server
.serve(server_transport)
.await
.expect("server should start")
.waiting()
.await;
if let Ok(running) = server.serve(server_transport).await {
let _ = running.waiting().await;
}
});
let client = VersionedClient {
protocol_version: client_version,
}
.serve(client_transport)
.await
.expect("client should connect");
.await?;
let version = client
.peer_info()
@ -113,20 +141,55 @@ async fn negotiated_version_with<S: ServerHandler>(
.clone();
client.cancel().await.expect("client should cancel");
version
Ok(version)
}
#[tokio::test]
async fn known_version_echoed_back() {
for version in ProtocolVersion::KNOWN_VERSIONS {
async fn handshake_version_echoed_back() {
for version in HANDSHAKE_VERSIONS {
let negotiated = negotiated_version(version.clone()).await;
assert_eq!(
negotiated, *version,
"known version {version} should be echoed back"
"handshake version {version} should be echoed back"
);
}
}
/// `initialize` disappeared in `2026-07-28`, so agreeing to it here would leave
/// the peers speaking a revision that has no handshake at all.
#[tokio::test]
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"
);
}
#[tokio::test]
async fn modern_only_server_rejects_the_handshake() {
let error = negotiate_with(ModernOnlyServer, ProtocolVersion::V_2026_07_28)
.await
.expect_err("a server with no handshake version cannot answer initialize");
let ClientInitializeError::JsonRpcError(error) = error else {
panic!("expected a JSON-RPC error, got {error:?}");
};
assert_eq!(
error.code,
ErrorCode::UNSUPPORTED_PROTOCOL_VERSION,
"a server with no handshake version should reject initialize"
);
assert_eq!(
error.data,
Some(serde_json::json!({
"requested": "2026-07-28",
"supported": ["2026-07-28"],
})),
"the rejection should name the versions the server does support"
);
}
#[tokio::test]
async fn unknown_version_falls_back_to_latest() {
let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap();
@ -140,7 +203,7 @@ async fn unknown_version_falls_back_to_latest() {
#[tokio::test]
async fn narrowed_server_still_echoes_versions_it_supports() {
for version in NARROWED_VERSIONS {
for version in HANDSHAKE_VERSIONS {
let negotiated = negotiated_version_with(NarrowedServer, version.clone()).await;
assert_eq!(
negotiated, *version,

View file

@ -6,12 +6,12 @@
#![cfg(feature = "client")]
use rmcp::{
ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt,
ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError,
model::{
ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams,
ReadResourceResponse,
ReadResourceResponse, ServerInfo,
},
service::RequestContext,
service::{RequestContext, serve_directly},
};
#[derive(Debug, Clone, Default)]
@ -40,24 +40,33 @@ impl ClientHandler for VersionedClient {
}
}
/// Wires the pair up directly on `client_version`. `2026-07-28` removed the
/// `initialize` handshake, so a peer on that revision is reached the way the
/// discover lifecycle leaves one: with the version already agreed.
async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let client_handler = VersionedClient {
protocol_version: client_version.clone(),
};
let mut server_peer_info = ServerInfo::default();
server_peer_info.protocol_version = client_version;
let server = serve_directly::<RoleServer, _, _, _, _>(
ResourceServer,
server_transport,
Some(client_handler.get_info()),
);
let server_handle = tokio::spawn(async move {
ResourceServer
.serve(server_transport)
.await?
.waiting()
.await?;
server.waiting().await?;
anyhow::Ok(())
});
let client = VersionedClient {
protocol_version: client_version,
}
.serve(client_transport)
.await
.expect("client should connect");
let client = serve_directly::<RoleClient, _, _, _, _>(
client_handler,
client_transport,
Some(server_peer_info.into()),
);
let error = client
.read_resource(ReadResourceRequestParams::new("missing://resource"))

View file

@ -6,12 +6,12 @@
#![cfg(feature = "client")]
use rmcp::{
ClientHandler, RoleServer, ServerHandler, ServiceExt,
ClientHandler, RoleClient, RoleServer, ServerHandler,
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock,
ErrorData, ProtocolVersion, ResultType,
ErrorData, ProtocolVersion, ResultType, ServerInfo,
},
service::RequestContext,
service::{RequestContext, serve_directly},
};
#[derive(Debug, Clone, Default)]
@ -40,20 +40,33 @@ impl ClientHandler for VersionedClient {
}
}
/// Wires the pair up directly on `client_version`. `2026-07-28` removed the
/// `initialize` handshake, so a peer on that revision is reached the way the
/// discover lifecycle leaves one: with the version already agreed.
async fn call_tool_result_type(client_version: ProtocolVersion) -> Option<ResultType> {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let client_handler = VersionedClient {
protocol_version: client_version.clone(),
};
let mut server_peer_info = ServerInfo::default();
server_peer_info.protocol_version = client_version;
let server = serve_directly::<RoleServer, _, _, _, _>(
ToolServer,
server_transport,
Some(client_handler.get_info()),
);
let server_handle = tokio::spawn(async move {
ToolServer.serve(server_transport).await?.waiting().await?;
server.waiting().await?;
anyhow::Ok(())
});
let client = VersionedClient {
protocol_version: client_version,
}
.serve(client_transport)
.await
.expect("client should connect");
let client = serve_directly::<RoleClient, _, _, _, _>(
client_handler,
client_transport,
Some(server_peer_info.into()),
);
let result = client
.call_tool(CallToolRequestParams::new("echo"))

View file

@ -10,7 +10,7 @@ use rmcp::{
CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion,
SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest,
},
service::RequestContext,
service::{RequestContext, RunningService, serve_directly},
};
use serde_json::{Value, json};
use tokio::{
@ -95,21 +95,44 @@ impl ClientHandler for SamplingClient {
}
}
/// Connects the pair on `2026-07-28`. That revision dropped the `initialize`
/// handshake, so the version is agreed up front the way a discover-lifecycle
/// startup leaves it.
fn serve_modern_pair(
server: SamplingServer,
) -> (
RunningService<RoleServer, SamplingServer>,
RunningService<RoleClient, SamplingClient>,
) {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let mut server_peer_info = server.get_info();
server_peer_info.protocol_version = ProtocolVersion::V_2026_07_28;
let running_server = serve_directly::<RoleServer, _, _, _, _>(
server,
server_transport,
Some(SamplingClient.get_info()),
);
let client = serve_directly::<RoleClient, _, _, _, _>(
SamplingClient,
client_transport,
Some(server_peer_info.into()),
);
(running_server, client)
}
#[tokio::test]
async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let (tx, rx) = oneshot::channel();
let server = SamplingServer {
outside: Arc::new(Mutex::new(Some(tx))),
};
let (running_server, client) = serve_modern_pair(server);
let server_handle = tokio::spawn(async move {
let running = server.serve(server_transport).await?;
running.waiting().await?;
running_server.waiting().await?;
anyhow::Ok(())
});
let client = SamplingClient.serve(client_transport).await?;
let result = client
.peer()
.call_tool(CallToolRequestParams::new("sample"))
@ -129,19 +152,16 @@ async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> {
#[tokio::test]
async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let (tx, rx) = oneshot::channel();
let server = SamplingServer {
outside: Arc::new(Mutex::new(Some(tx))),
};
let (running_server, client) = serve_modern_pair(server);
let server_handle = tokio::spawn(async move {
let running = server.serve(server_transport).await?;
running.waiting().await?;
running_server.waiting().await?;
anyhow::Ok(())
});
let client = SamplingClient.serve(client_transport).await?;
let result = client
.peer()
.call_tool(CallToolRequestParams::new("sample_generic"))

View file

@ -1,7 +1,8 @@
//! Tests for protocol version negotiation in stateless HTTP mode.
//!
//! Supported versions are echoed back; unknown versions, and versions outside
//! the server's `supported_protocol_versions`, fall back to the handler's own
//! Supported handshake versions are echoed back; unknown versions, versions
//! outside the server's `supported_protocol_versions`, and versions that no
//! longer have an `initialize` handshake fall back to the handler's own
//! version.
#![cfg(not(feature = "local"))]
@ -36,9 +37,10 @@ impl ServerHandler for OverridingInitialize {
}
}
/// Every known version except `2026-07-28`, standing in for a server that has
/// not implemented that revision.
const NARROWED_VERSIONS: &[ProtocolVersion] = &[
/// 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.
const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[
ProtocolVersion::V_2024_11_05,
ProtocolVersion::V_2025_03_26,
ProtocolVersion::V_2025_06_18,
@ -56,7 +58,7 @@ impl ServerHandler for NarrowedOverridingInitialize {
}
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(NARROWED_VERSIONS)
Cow::Borrowed(HANDSHAKE_VERSIONS)
}
async fn initialize(
@ -148,15 +150,15 @@ async fn post_init(client: &reqwest::Client, url: &str, body_version: &str) -> s
}
#[tokio::test]
async fn stateless_json_init_echoes_known_versions_when_handler_overrides_initialize() {
async fn stateless_json_init_echoes_handshake_versions_when_handler_overrides_initialize() {
let (client, url, ct) = spawn_server(stateless_json_config()).await;
for version in ProtocolVersion::KNOWN_VERSIONS {
for version in HANDSHAKE_VERSIONS {
let resp = post_init(&client, &url, version.as_str()).await;
assert_eq!(
resp["result"]["protocolVersion"],
version.as_str(),
"known version {version} should be echoed back"
"handshake version {version} should be echoed back"
);
}
@ -164,15 +166,15 @@ async fn stateless_json_init_echoes_known_versions_when_handler_overrides_initia
}
#[tokio::test]
async fn stateless_sse_init_echoes_known_versions_when_handler_overrides_initialize() {
async fn stateless_sse_init_echoes_handshake_versions_when_handler_overrides_initialize() {
let (client, url, ct) = spawn_server(stateless_sse_config()).await;
for version in ProtocolVersion::KNOWN_VERSIONS {
for version in HANDSHAKE_VERSIONS {
let resp = post_init(&client, &url, version.as_str()).await;
assert_eq!(
resp["result"]["protocolVersion"],
version.as_str(),
"known version {version} should be echoed back"
"handshake version {version} should be echoed back"
);
}
@ -198,7 +200,7 @@ async fn stateless_json_init_echoes_versions_the_server_narrowed_to() {
let (client, url, ct) =
spawn_server_of::<NarrowedOverridingInitialize>(stateless_json_config()).await;
for version in NARROWED_VERSIONS {
for version in HANDSHAKE_VERSIONS {
let resp = post_init(&client, &url, version.as_str()).await;
assert_eq!(
resp["result"]["protocolVersion"],

View file

@ -87,6 +87,17 @@ async fn post_init(
req.send().await.expect("send initialize request")
}
/// First JSON-RPC message of an SSE response, skipping the empty priming event.
async fn sse_payload(response: reqwest::Response) -> anyhow::Result<Value> {
let body = response.text().await?;
let data = body
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.find(|data| !data.is_empty())
.expect("response must carry a JSON-RPC payload");
Ok(serde_json::from_str(data)?)
}
async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Response {
client
.post(url)
@ -186,6 +197,27 @@ async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn stateless_init_naming_a_post_handshake_version_negotiates_down() -> anyhow::Result<()> {
let (client, url, ct) = spawn_server(stateless_json_config()).await;
let response = post_init(&client, &url, None, "2026-07-28").await;
assert_eq!(
response.status(),
200,
"initialize selects legacy semantics whatever version it names"
);
let body: serde_json::Value = response.json().await?;
assert_eq!(
body["result"]["protocolVersion"], "2025-11-25",
"initialize must settle on a version that still has the handshake"
);
ct.cancel();
Ok(())
}
#[tokio::test]
async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> {
let (client, url, ct) = spawn_server(stateful_config()).await;
@ -218,6 +250,37 @@ async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Res
Ok(())
}
/// The `initialize` handshake exists only in the revisions before `2026-07-28`,
/// so naming a later version in it does not make the request a modern one: the
/// server keeps legacy semantics, opens a session, and answers with a version it
/// can actually serve over the handshake.
#[tokio::test]
async fn stateful_init_naming_a_post_handshake_version_opens_a_legacy_session() -> anyhow::Result<()>
{
let (client, url, ct) = spawn_server(stateful_config()).await;
let response = post_init(&client, &url, None, "2026-07-28").await;
assert_eq!(
response.status(),
200,
"initialize selects legacy semantics whatever version it names"
);
assert!(
response.headers().contains_key("Mcp-Session-Id"),
"the handshake must open a session, got headers: {:?}",
response.headers()
);
let payload = sse_payload(response).await?;
assert_eq!(
payload["result"]["protocolVersion"], "2025-11-25",
"initialize must settle on a version that still has the handshake"
);
ct.cancel();
Ok(())
}
#[tokio::test]
async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> {
let (client, url, ct) = spawn_server(stateless_json_config()).await;