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
57 changes: 51 additions & 6 deletions crates/rmcp/src/service/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,22 +158,35 @@ where
.ok_or_else(|| ClientInitializeError::ConnectionClosed(context.to_string()))
}

/// Whether `expect_response` answers a server ping that arrives before the
/// handshake completes.
#[derive(Clone, Copy)]
enum PreInitPing {
Answer,
Ignore,
}

/// Helper function to expect a response from the stream, correlated to
/// `expected_id`.
///
/// Both success and error responses are checked here: a mismatched id on a
/// success response is `ConflictInitResponseId`; on an error response (whose
/// `id` is optional per spec) it is `UncorrelatedErrorResponse`. The caller
/// never sees an uncorrelated response.
///
/// A server ping is answered only with `PreInitPing::Answer`. The discover
/// wait, including Auto's timeout-bounded probe, leaves it unanswered:
/// 2026-07-28 has no ping, and a cancelled send could leave a partial frame.
async fn expect_response<T, S>(
transport: &mut T,
context: &str,
service: &S,
peer: Peer<RoleClient>,
expected_id: &RequestId,
pre_init_ping: PreInitPing,
) -> Result<ServerResult, ClientInitializeError>
where
T: Transport<RoleClient>,
T: Transport<RoleClient> + 'static,
S: Service<RoleClient>,
{
loop {
Expand Down Expand Up @@ -231,10 +244,26 @@ where
}
}
// Server could send pings before handshake
ServerJsonRpcMessage::Request(ref request)
ServerJsonRpcMessage::Request(request)
if matches!(request.request, ServerRequest::PingRequest(_)) =>
{
tracing::trace!("Received ping request. Ignored.")
match pre_init_ping {
PreInitPing::Answer => {
transport
.send(ClientJsonRpcMessage::response(
ClientResult::empty(()),
request.id,
))
.await
.map_err(|error| {
ClientInitializeError::transport::<T>(
error,
"send pre-init ping response",
)
})?;
}
PreInitPing::Ignore => tracing::trace!("Received ping request. Ignored."),
}
}
// Server SHOULD NOT send any other messages before handshake. We ignore them anyway
_ => tracing::warn!(?message, "Received unexpected message"),
Expand Down Expand Up @@ -915,8 +944,15 @@ where
context: "send initialize request".into(),
})?;

let response =
expect_response(transport, "initialize response", service, peer.clone(), &id).await?;
let response = expect_response(
transport,
"initialize response",
service,
peer.clone(),
&id,
PreInitPing::Answer,
)
.await?;

let ServerResult::InitializeResult(initialize_result) = response else {
return Err(ClientInitializeError::ExpectedInitResult(Some(response)));
Expand Down Expand Up @@ -986,7 +1022,16 @@ where
ClientInitializeError::transport::<T>(error, "send discover request")
})?;

match expect_response(transport, "discover response", service, peer.clone(), &id).await {
match expect_response(
transport,
"discover response",
service,
peer.clone(),
&id,
PreInitPing::Ignore,
)
.await
{
Ok(ServerResult::DiscoverResult(result)) => {
let Some(selected) =
select_protocol_version(&preferred_versions, &result.supported_versions)
Expand Down
55 changes: 53 additions & 2 deletions crates/rmcp/tests/test_client_initialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ use common::handlers::TestClientHandler;
use rmcp::{
ServiceExt,
model::{
ClientJsonRpcMessage, ErrorCode, ErrorData, InitializeResult, JsonRpcError,
JsonRpcVersion2_0, RequestId, ServerCapabilities, ServerJsonRpcMessage, ServerResult,
ClientConfig, ClientJsonRpcMessage, ClientNotification, ClientResult, ErrorCode, ErrorData,
InitializeResult, JsonRpcError, JsonRpcVersion2_0, PingRequest, ProtocolVersion, RequestId,
ServerCapabilities, ServerJsonRpcMessage, ServerRequest, ServerResult,
},
transport::{IntoTransport, Transport},
};
Expand Down Expand Up @@ -157,3 +158,53 @@ async fn test_client_init_handles_jsonrpc_error() {
_ => panic!("Expected ClientInitializeError::JsonRpcError"),
}
}

#[tokio::test]
async fn client_answers_server_ping_during_initialize() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let mut server = IntoTransport::<rmcp::RoleServer, _, _>::into_transport(server_transport);
let server_task = tokio::spawn(async move {
let ClientJsonRpcMessage::Request(initialize) =
server.receive().await.expect("expected initialize request")
else {
panic!("expected initialize request");
};
let ping_id = RequestId::String("server-ping".into());
server
.send(ServerJsonRpcMessage::request(
ServerRequest::PingRequest(PingRequest::default()),
ping_id.clone(),
))
.await
.expect("send ping");
server
.send(ServerJsonRpcMessage::response(
ServerResult::InitializeResult(
InitializeResult::new(ServerCapabilities::default())
.with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE),
),
initialize.id,
))
.await
.expect("send initialize response");

let Some(ClientJsonRpcMessage::Response(response)) = server.receive().await else {
panic!("expected the ping response before notifications/initialized");
};
assert_eq!(response.id, ping_id);
assert!(matches!(response.result, ClientResult::EmptyResult(_)));
assert!(matches!(
server.receive().await,
Some(ClientJsonRpcMessage::Notification(notification))
if matches!(notification.notification, ClientNotification::InitializedNotification(_))
));
Comment thread
DaleSeo marked this conversation as resolved.
});

let client = ClientConfig::default()
.with_protocol_version(ProtocolVersion::LATEST_WITH_INITIALIZE)
.serve(client_transport)
.await
.expect("initialize client");
client.cancel().await.expect("cancel client");
server_task.await.expect("server task");
}
63 changes: 61 additions & 2 deletions crates/rmcp/tests/test_client_lifecycle_modes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use rmcp::{
ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt,
model::{
ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, GetMeta,
Implementation, InitializeResult, ProtocolVersion, RequestId, ServerCapabilities,
ServerJsonRpcMessage, ServerResult,
Implementation, InitializeResult, PingRequest, ProtocolVersion, RequestId,
ServerCapabilities, ServerJsonRpcMessage, ServerRequest, ServerResult,
},
service::PeerRequestOptions,
transport::{IntoTransport, Transport},
Expand Down Expand Up @@ -277,6 +277,65 @@ async fn discover_startup_omits_initialize() {
server_task.await.expect("server task");
}

#[tokio::test]
async fn discover_startup_ignores_server_ping() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let mut server = IntoTransport::<rmcp::RoleServer, _, _>::into_transport(server_transport);
let server_task = tokio::spawn(async move {
let ClientJsonRpcMessage::Request(request) =
server.receive().await.expect("expected discover request")
else {
panic!("expected discover request");
};
server
.send(ServerJsonRpcMessage::request(
ServerRequest::PingRequest(PingRequest::default()),
RequestId::String("server-ping".into()),
))
.await
.expect("send ping");
server
.send(ServerJsonRpcMessage::response(
ServerResult::DiscoverResult(DiscoverResult::new(
vec![ProtocolVersion::V_2026_07_28],
ServerCapabilities::default(),
)),
request.id,
))
.await
.expect("send discover response");

// Anything written for the ping would arrive before this request.
let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else {
panic!("expected the next client request, not a ping response");
};
assert!(matches!(
request.request,
ClientRequest::ListToolsRequest(_)
));
server
.send(ServerJsonRpcMessage::response(
ServerResult::ListToolsResult(Default::default()),
request.id,
))
.await
.expect("send tools response");
});

let client = DiscoverClient
.serve_with_lifecycle(
client_transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await
.expect("discover client should start");
client.list_tools(None).await.expect("list tools");
client.cancel().await.expect("cancel client");
server_task.await.expect("server task");
}

#[tokio::test]
async fn auto_startup_falls_back_after_discover_method_not_found() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
Expand Down
Loading