fix: keep initialize on legacy protocol versions (#1228)
Claude-Session: https://claude.ai/code/session_01EPcBfiUWJJueJn5cve1sjp
This commit is contained in:
parent
4e74499783
commit
ad9832ec21
11 changed files with 369 additions and 120 deletions
|
|
@ -322,12 +322,15 @@ macro_rules! server_handler_methods {
|
||||||
) -> impl Future<Output = Result<InitializeResult, McpError>> + MaybeSendFuture + '_ {
|
) -> impl Future<Output = Result<InitializeResult, McpError>> + MaybeSendFuture + '_ {
|
||||||
context.peer.set_peer_info(request.clone());
|
context.peer.set_peer_info(request.clone());
|
||||||
let mut info = self.get_info();
|
let mut info = self.get_info();
|
||||||
info.protocol_version = negotiate_protocol_version(
|
let negotiated = negotiate_protocol_version(
|
||||||
&request.protocol_version,
|
&request.protocol_version,
|
||||||
info.protocol_version,
|
std::mem::take(&mut info.protocol_version),
|
||||||
&self.supported_protocol_versions(),
|
&self.supported_protocol_versions(),
|
||||||
);
|
);
|
||||||
std::future::ready(Ok(info))
|
std::future::ready(negotiated.map(|version| {
|
||||||
|
info.protocol_version = version;
|
||||||
|
info
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/// Return the protocol versions supported by this server.
|
/// Return the protocol versions supported by this server.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -193,12 +193,17 @@ pub enum PeerRequestAssociation {
|
||||||
Unknown { has_pending_outbound_request: bool },
|
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(
|
pub(crate) fn uses_legacy_lifecycle(
|
||||||
protocol_version: Option<&ProtocolVersion>,
|
protocol_version: Option<&ProtocolVersion>,
|
||||||
uses_discover_lifecycle: bool,
|
uses_discover_lifecycle: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
!uses_discover_lifecycle
|
!uses_discover_lifecycle && protocol_version.is_none_or(is_legacy_version)
|
||||||
&& protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn peer_request_association<Req: crate::model::GetExtensions, V>(
|
pub(crate) fn peer_request_association<Req: crate::model::GetExtensions, V>(
|
||||||
|
|
|
||||||
|
|
@ -460,27 +460,60 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Echoes the client-requested version if the server supports it; otherwise
|
/// Echoes the client-requested version if the server can serve it over the
|
||||||
/// returns `server_fallback`.
|
/// `initialize` handshake; otherwise returns a legacy version the server does
|
||||||
|
/// support.
|
||||||
///
|
///
|
||||||
/// `server_supported` comes from [`Service::supported_protocol_versions`], so a
|
/// `server_supported` comes from [`Service::supported_protocol_versions`], so a
|
||||||
/// server that narrows that list is never made to answer `initialize` with a
|
/// server that narrows that list is never made to answer `initialize` with a
|
||||||
/// version it cannot serve.
|
/// 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.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`] when the server supports
|
||||||
|
/// no version that still has an `initialize` handshake.
|
||||||
|
///
|
||||||
|
/// [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`]: crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION
|
||||||
pub(crate) fn negotiate_protocol_version(
|
pub(crate) fn negotiate_protocol_version(
|
||||||
client_requested: &ProtocolVersion,
|
client_requested: &ProtocolVersion,
|
||||||
server_fallback: ProtocolVersion,
|
server_fallback: ProtocolVersion,
|
||||||
server_supported: &[ProtocolVersion],
|
server_supported: &[ProtocolVersion],
|
||||||
) -> ProtocolVersion {
|
) -> Result<ProtocolVersion, ErrorData> {
|
||||||
if server_supported.contains(client_requested) {
|
if is_legacy_version(client_requested) && server_supported.contains(client_requested) {
|
||||||
client_requested.clone()
|
return Ok(client_requested.clone());
|
||||||
|
}
|
||||||
|
let legacy_fallback = if is_legacy_version(&server_fallback) {
|
||||||
|
Some(server_fallback)
|
||||||
} else {
|
} else {
|
||||||
|
newest_legacy_version(server_supported)
|
||||||
|
};
|
||||||
|
let Some(legacy_fallback) = legacy_fallback else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
client_requested = %client_requested,
|
client_requested = %client_requested,
|
||||||
server_fallback = %server_fallback,
|
"server supports no protocol version with an initialize handshake; rejecting"
|
||||||
"client requested unsupported protocol version; falling back to server default"
|
|
||||||
);
|
);
|
||||||
server_fallback
|
return Err(ErrorData::unsupported_protocol_version(
|
||||||
}
|
client_requested.clone(),
|
||||||
|
server_supported,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
tracing::warn!(
|
||||||
|
client_requested = %client_requested,
|
||||||
|
server_fallback = %legacy_fallback,
|
||||||
|
"client requested a protocol version unavailable over initialize; falling back to server default"
|
||||||
|
);
|
||||||
|
Ok(legacy_fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest of `versions` that still has an `initialize` handshake.
|
||||||
|
fn newest_legacy_version(versions: &[ProtocolVersion]) -> Option<ProtocolVersion> {
|
||||||
|
versions
|
||||||
|
.iter()
|
||||||
|
.filter(|version| is_legacy_version(version))
|
||||||
|
.max_by(|left, right| left.as_str().cmp(right.as_str()))
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn missing_request_metadata_error(missing: &[&str]) -> ErrorData {
|
fn missing_request_metadata_error(missing: &[&str]) -> ErrorData {
|
||||||
|
|
@ -493,6 +526,27 @@ fn missing_request_metadata_error(missing: &[&str]) -> ErrorData {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends `error` as the response to the `initialize` request and reports it as
|
||||||
|
/// the reason the handshake failed.
|
||||||
|
async fn report_initialize_failure<T>(
|
||||||
|
transport: &mut T,
|
||||||
|
error: ErrorData,
|
||||||
|
id: RequestId,
|
||||||
|
) -> ServerInitializeError
|
||||||
|
where
|
||||||
|
T: Transport<RoleServer> + 'static,
|
||||||
|
{
|
||||||
|
match transport
|
||||||
|
.send(ServerJsonRpcMessage::error(error.clone(), Some(id)))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => ServerInitializeError::InitializeFailed(error),
|
||||||
|
Err(send_error) => {
|
||||||
|
ServerInitializeError::transport::<T>(send_error, "sending error response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn serve_server_with_ct_inner<S, T>(
|
async fn serve_server_with_ct_inner<S, T>(
|
||||||
service: S,
|
service: S,
|
||||||
transport: T,
|
transport: T,
|
||||||
|
|
@ -589,27 +643,21 @@ where
|
||||||
peer: peer.clone(),
|
peer: peer.clone(),
|
||||||
};
|
};
|
||||||
// Send initialize response
|
// Send initialize response
|
||||||
let init_response = service.handle_request(request, context).await;
|
let mut init_response = match service.handle_request(request, context).await {
|
||||||
let mut init_response = match init_response {
|
|
||||||
Ok(ServerResult::InitializeResult(init_response)) => init_response,
|
Ok(ServerResult::InitializeResult(init_response)) => init_response,
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
return Err(ServerInitializeError::UnexpectedInitializeResponse(result));
|
return Err(ServerInitializeError::UnexpectedInitializeResponse(result));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => return Err(report_initialize_failure(&mut transport, e, id).await),
|
||||||
transport
|
|
||||||
.send(ServerJsonRpcMessage::error(e.clone(), Some(id)))
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
ServerInitializeError::transport::<T>(error, "sending error response")
|
|
||||||
})?;
|
|
||||||
return Err(ServerInitializeError::InitializeFailed(e));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
init_response.protocol_version = negotiate_protocol_version(
|
init_response.protocol_version = match negotiate_protocol_version(
|
||||||
&requested_protocol_version,
|
&requested_protocol_version,
|
||||||
init_response.protocol_version,
|
init_response.protocol_version,
|
||||||
&service.supported_protocol_versions(),
|
&service.supported_protocol_versions(),
|
||||||
);
|
) {
|
||||||
|
Ok(version) => version,
|
||||||
|
Err(e) => return Err(report_initialize_failure(&mut transport, e, id).await),
|
||||||
|
};
|
||||||
// Update peer_info so context.protocol_version() reflects the negotiated
|
// Update peer_info so context.protocol_version() reflects the negotiated
|
||||||
// version in all subsequent request handlers.
|
// version in all subsequent request handlers.
|
||||||
negotiated_peer_info.protocol_version = init_response.protocol_version.clone();
|
negotiated_peer_info.protocol_version = init_response.protocol_version.clone();
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ impl<S: Service<RoleServer>> Service<RoleServer> for NegotiatingStatelessHttpSer
|
||||||
&requested,
|
&requested,
|
||||||
result.protocol_version.clone(),
|
result.protocol_version.clone(),
|
||||||
&self.0.supported_protocol_versions(),
|
&self.0.supported_protocol_versions(),
|
||||||
);
|
)?;
|
||||||
if let Some(peer_info) = peer.peer_info() {
|
if let Some(peer_info) = peer.peer_info() {
|
||||||
let mut peer_info = (*peer_info).clone();
|
let mut peer_info = (*peer_info).clone();
|
||||||
peer_info.protocol_version = result.protocol_version.clone();
|
peer_info.protocol_version = result.protocol_version.clone();
|
||||||
|
|
@ -375,22 +375,30 @@ fn is_legacy_request(
|
||||||
validate_request_protocol_version_meta(headers, message)?;
|
validate_request_protocol_version_meta(headers, message)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An `initialize` request selects legacy semantics whatever version it names:
|
||||||
|
// the handshake exists only in the revisions before 2026-07-28, so the
|
||||||
|
// version in its params never routes it to the stateless path. The
|
||||||
|
// handshake itself answers with a legacy version the server supports.
|
||||||
|
if matches!(
|
||||||
|
message,
|
||||||
|
Some(ClientJsonRpcMessage::Request(req))
|
||||||
|
if matches!(&req.request, ClientRequest::InitializeRequest(_))
|
||||||
|
) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
let uses_discover_lifecycle = matches!(
|
let uses_discover_lifecycle = matches!(
|
||||||
message,
|
message,
|
||||||
Some(ClientJsonRpcMessage::Request(req))
|
Some(ClientJsonRpcMessage::Request(req))
|
||||||
if !matches!(&req.request, ClientRequest::InitializeRequest(_))
|
if req
|
||||||
&& req
|
.request
|
||||||
.request
|
.get_meta()
|
||||||
.get_meta()
|
.missing_required_keys(&ProtocolVersion::V_2026_07_28)
|
||||||
.missing_required_keys(&ProtocolVersion::V_2026_07_28)
|
.is_empty()
|
||||||
.is_empty()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let from_body = match message {
|
let from_body = match message {
|
||||||
Some(ClientJsonRpcMessage::Request(req)) => match &req.request {
|
Some(ClientJsonRpcMessage::Request(req)) => req.request.get_meta().protocol_version(),
|
||||||
ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()),
|
|
||||||
_ => req.request.get_meta().protocol_version(),
|
|
||||||
},
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let version = from_body
|
let version = from_body
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@
|
||||||
#![cfg(feature = "client")]
|
#![cfg(feature = "client")]
|
||||||
|
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ClientHandler, ServerHandler, ServiceExt,
|
ClientHandler, RoleClient, RoleServer, ServerHandler,
|
||||||
handler::server::router::{prompt::PromptRouter, tool::ToolRouter},
|
handler::server::router::{prompt::PromptRouter, tool::ToolRouter},
|
||||||
model::{CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion},
|
model::{
|
||||||
prompt_handler, tool_handler,
|
CacheScope, ClientInfo, ListPromptsResult, ListToolsResult, ProtocolVersion, ServerInfo,
|
||||||
|
},
|
||||||
|
prompt_handler,
|
||||||
|
service::serve_directly,
|
||||||
|
tool_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[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) {
|
async fn list_results(protocol_version: ProtocolVersion) -> (ListToolsResult, ListPromptsResult) {
|
||||||
let (server_transport, client_transport) = tokio::io::duplex(4096);
|
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 {
|
let server_handle = tokio::spawn(async move {
|
||||||
CacheHintServer::new()
|
server.waiting().await?;
|
||||||
.serve(server_transport)
|
|
||||||
.await?
|
|
||||||
.waiting()
|
|
||||||
.await?;
|
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = VersionedClient { protocol_version }
|
let client = serve_directly::<RoleClient, _, _, _, _>(
|
||||||
.serve(client_transport)
|
client_handler,
|
||||||
.await
|
client_transport,
|
||||||
.expect("client should connect");
|
Some(server_peer_info.into()),
|
||||||
|
);
|
||||||
let tools = client
|
let tools = client
|
||||||
.list_tools(None)
|
.list_tools(None)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Tests for protocol version negotiation in the default ServerHandler::initialize impl.
|
//! 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(not(feature = "local"))]
|
||||||
#![cfg(feature = "client")]
|
#![cfg(feature = "client")]
|
||||||
|
|
||||||
|
|
@ -8,8 +9,11 @@ use std::borrow::Cow;
|
||||||
|
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt,
|
ClientHandler, ErrorData, RoleServer, ServerHandler, ServiceExt,
|
||||||
model::{ClientInfo, InitializeRequestParams, InitializeResult, ProtocolVersion, ServerInfo},
|
model::{
|
||||||
service::RequestContext,
|
ClientInfo, ErrorCode, InitializeRequestParams, InitializeResult, ProtocolVersion,
|
||||||
|
ServerInfo,
|
||||||
|
},
|
||||||
|
service::{ClientInitializeError, RequestContext},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[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
|
/// Every known version whose lifecycle still runs the `initialize` handshake.
|
||||||
/// not implemented that revision.
|
/// `2026-07-28` replaced the handshake with per-request metadata, so this is
|
||||||
const NARROWED_VERSIONS: &[ProtocolVersion] = &[
|
/// also the list a server that has not implemented that revision supports.
|
||||||
|
const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[
|
||||||
ProtocolVersion::V_2024_11_05,
|
ProtocolVersion::V_2024_11_05,
|
||||||
ProtocolVersion::V_2025_03_26,
|
ProtocolVersion::V_2025_03_26,
|
||||||
ProtocolVersion::V_2025_06_18,
|
ProtocolVersion::V_2025_06_18,
|
||||||
|
|
@ -39,7 +44,25 @@ impl ServerHandler for NarrowedServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
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]> {
|
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||||
Cow::Borrowed(NARROWED_VERSIONS)
|
Cow::Borrowed(HANDSHAKE_VERSIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn initialize(
|
async fn initialize(
|
||||||
|
|
@ -88,23 +111,28 @@ async fn negotiated_version_with<S: ServerHandler>(
|
||||||
server: S,
|
server: S,
|
||||||
client_version: ProtocolVersion,
|
client_version: ProtocolVersion,
|
||||||
) -> 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);
|
let (server_transport, client_transport) = tokio::io::duplex(4096);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = server
|
if let Ok(running) = server.serve(server_transport).await {
|
||||||
.serve(server_transport)
|
let _ = running.waiting().await;
|
||||||
.await
|
}
|
||||||
.expect("server should start")
|
|
||||||
.waiting()
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = VersionedClient {
|
let client = VersionedClient {
|
||||||
protocol_version: client_version,
|
protocol_version: client_version,
|
||||||
}
|
}
|
||||||
.serve(client_transport)
|
.serve(client_transport)
|
||||||
.await
|
.await?;
|
||||||
.expect("client should connect");
|
|
||||||
|
|
||||||
let version = client
|
let version = client
|
||||||
.peer_info()
|
.peer_info()
|
||||||
|
|
@ -113,20 +141,55 @@ async fn negotiated_version_with<S: ServerHandler>(
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
client.cancel().await.expect("client should cancel");
|
client.cancel().await.expect("client should cancel");
|
||||||
version
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn known_version_echoed_back() {
|
async fn handshake_version_echoed_back() {
|
||||||
for version in ProtocolVersion::KNOWN_VERSIONS {
|
for version in HANDSHAKE_VERSIONS {
|
||||||
let negotiated = negotiated_version(version.clone()).await;
|
let negotiated = negotiated_version(version.clone()).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
negotiated, *version,
|
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]
|
#[tokio::test]
|
||||||
async fn unknown_version_falls_back_to_latest() {
|
async fn unknown_version_falls_back_to_latest() {
|
||||||
let unknown: ProtocolVersion = serde_json::from_str(r#""1999-01-01""#).unwrap();
|
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]
|
#[tokio::test]
|
||||||
async fn narrowed_server_still_echoes_versions_it_supports() {
|
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;
|
let negotiated = negotiated_version_with(NarrowedServer, version.clone()).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
negotiated, *version,
|
negotiated, *version,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
#![cfg(feature = "client")]
|
#![cfg(feature = "client")]
|
||||||
|
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt,
|
ClientHandler, RoleClient, RoleServer, ServerHandler, ServiceError,
|
||||||
model::{
|
model::{
|
||||||
ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams,
|
ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams,
|
||||||
ReadResourceResponse,
|
ReadResourceResponse, ServerInfo,
|
||||||
},
|
},
|
||||||
service::RequestContext,
|
service::{RequestContext, serve_directly},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[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 {
|
async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode {
|
||||||
let (server_transport, client_transport) = tokio::io::duplex(4096);
|
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 {
|
let server_handle = tokio::spawn(async move {
|
||||||
ResourceServer
|
server.waiting().await?;
|
||||||
.serve(server_transport)
|
|
||||||
.await?
|
|
||||||
.waiting()
|
|
||||||
.await?;
|
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = VersionedClient {
|
let client = serve_directly::<RoleClient, _, _, _, _>(
|
||||||
protocol_version: client_version,
|
client_handler,
|
||||||
}
|
client_transport,
|
||||||
.serve(client_transport)
|
Some(server_peer_info.into()),
|
||||||
.await
|
);
|
||||||
.expect("client should connect");
|
|
||||||
|
|
||||||
let error = client
|
let error = client
|
||||||
.read_resource(ReadResourceRequestParams::new("missing://resource"))
|
.read_resource(ReadResourceRequestParams::new("missing://resource"))
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
#![cfg(feature = "client")]
|
#![cfg(feature = "client")]
|
||||||
|
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ClientHandler, RoleServer, ServerHandler, ServiceExt,
|
ClientHandler, RoleClient, RoleServer, ServerHandler,
|
||||||
model::{
|
model::{
|
||||||
CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock,
|
CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock,
|
||||||
ErrorData, ProtocolVersion, ResultType,
|
ErrorData, ProtocolVersion, ResultType, ServerInfo,
|
||||||
},
|
},
|
||||||
service::RequestContext,
|
service::{RequestContext, serve_directly},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[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> {
|
async fn call_tool_result_type(client_version: ProtocolVersion) -> Option<ResultType> {
|
||||||
let (server_transport, client_transport) = tokio::io::duplex(4096);
|
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 {
|
let server_handle = tokio::spawn(async move {
|
||||||
ToolServer.serve(server_transport).await?.waiting().await?;
|
server.waiting().await?;
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = VersionedClient {
|
let client = serve_directly::<RoleClient, _, _, _, _>(
|
||||||
protocol_version: client_version,
|
client_handler,
|
||||||
}
|
client_transport,
|
||||||
.serve(client_transport)
|
Some(server_peer_info.into()),
|
||||||
.await
|
);
|
||||||
.expect("client should connect");
|
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.call_tool(CallToolRequestParams::new("echo"))
|
.call_tool(CallToolRequestParams::new("echo"))
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use rmcp::{
|
||||||
CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion,
|
CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, ProtocolVersion,
|
||||||
SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest,
|
SamplingMessage, ServerCapabilities, ServerInfo, ServerRequest,
|
||||||
},
|
},
|
||||||
service::RequestContext,
|
service::{RequestContext, RunningService, serve_directly},
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::{
|
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]
|
#[tokio::test]
|
||||||
async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> {
|
async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> {
|
||||||
let (server_transport, client_transport) = tokio::io::duplex(4096);
|
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
let server = SamplingServer {
|
let server = SamplingServer {
|
||||||
outside: Arc::new(Mutex::new(Some(tx))),
|
outside: Arc::new(Mutex::new(Some(tx))),
|
||||||
};
|
};
|
||||||
|
let (running_server, client) = serve_modern_pair(server);
|
||||||
let server_handle = tokio::spawn(async move {
|
let server_handle = tokio::spawn(async move {
|
||||||
let running = server.serve(server_transport).await?;
|
running_server.waiting().await?;
|
||||||
running.waiting().await?;
|
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = SamplingClient.serve(client_transport).await?;
|
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.peer()
|
.peer()
|
||||||
.call_tool(CallToolRequestParams::new("sample"))
|
.call_tool(CallToolRequestParams::new("sample"))
|
||||||
|
|
@ -129,19 +152,16 @@ async fn nested_sampling_allowed_standalone_rejected() -> anyhow::Result<()> {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> {
|
async fn generic_send_request_bypass_rejected() -> anyhow::Result<()> {
|
||||||
let (server_transport, client_transport) = tokio::io::duplex(4096);
|
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
let server = SamplingServer {
|
let server = SamplingServer {
|
||||||
outside: Arc::new(Mutex::new(Some(tx))),
|
outside: Arc::new(Mutex::new(Some(tx))),
|
||||||
};
|
};
|
||||||
|
let (running_server, client) = serve_modern_pair(server);
|
||||||
let server_handle = tokio::spawn(async move {
|
let server_handle = tokio::spawn(async move {
|
||||||
let running = server.serve(server_transport).await?;
|
running_server.waiting().await?;
|
||||||
running.waiting().await?;
|
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = SamplingClient.serve(client_transport).await?;
|
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.peer()
|
.peer()
|
||||||
.call_tool(CallToolRequestParams::new("sample_generic"))
|
.call_tool(CallToolRequestParams::new("sample_generic"))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
//! Tests for protocol version negotiation in stateless HTTP mode.
|
//! Tests for protocol version negotiation in stateless HTTP mode.
|
||||||
//!
|
//!
|
||||||
//! Supported versions are echoed back; unknown versions, and versions outside
|
//! Supported handshake versions are echoed back; unknown versions, versions
|
||||||
//! the server's `supported_protocol_versions`, fall back to the handler's own
|
//! outside the server's `supported_protocol_versions`, and versions that no
|
||||||
|
//! longer have an `initialize` handshake fall back to the handler's own
|
||||||
//! version.
|
//! version.
|
||||||
#![cfg(not(feature = "local"))]
|
#![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
|
/// Every known version whose lifecycle still runs the `initialize` handshake.
|
||||||
/// not implemented that revision.
|
/// `2026-07-28` replaced the handshake with per-request metadata, so this is
|
||||||
const NARROWED_VERSIONS: &[ProtocolVersion] = &[
|
/// also the list a server that has not implemented that revision supports.
|
||||||
|
const HANDSHAKE_VERSIONS: &[ProtocolVersion] = &[
|
||||||
ProtocolVersion::V_2024_11_05,
|
ProtocolVersion::V_2024_11_05,
|
||||||
ProtocolVersion::V_2025_03_26,
|
ProtocolVersion::V_2025_03_26,
|
||||||
ProtocolVersion::V_2025_06_18,
|
ProtocolVersion::V_2025_06_18,
|
||||||
|
|
@ -56,7 +58,7 @@ impl ServerHandler for NarrowedOverridingInitialize {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||||
Cow::Borrowed(NARROWED_VERSIONS)
|
Cow::Borrowed(HANDSHAKE_VERSIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn initialize(
|
async fn initialize(
|
||||||
|
|
@ -148,15 +150,15 @@ async fn post_init(client: &reqwest::Client, url: &str, body_version: &str) -> s
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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;
|
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;
|
let resp = post_init(&client, &url, version.as_str()).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp["result"]["protocolVersion"],
|
resp["result"]["protocolVersion"],
|
||||||
version.as_str(),
|
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]
|
#[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;
|
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;
|
let resp = post_init(&client, &url, version.as_str()).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp["result"]["protocolVersion"],
|
resp["result"]["protocolVersion"],
|
||||||
version.as_str(),
|
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) =
|
let (client, url, ct) =
|
||||||
spawn_server_of::<NarrowedOverridingInitialize>(stateless_json_config()).await;
|
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;
|
let resp = post_init(&client, &url, version.as_str()).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp["result"]["protocolVersion"],
|
resp["result"]["protocolVersion"],
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,17 @@ async fn post_init(
|
||||||
req.send().await.expect("send initialize request")
|
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 {
|
async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Response {
|
||||||
client
|
client
|
||||||
.post(url)
|
.post(url)
|
||||||
|
|
@ -186,6 +197,27 @@ async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> {
|
||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> {
|
async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> {
|
||||||
let (client, url, ct) = spawn_server(stateful_config()).await;
|
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(())
|
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]
|
#[tokio::test]
|
||||||
async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> {
|
async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> {
|
||||||
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue