feat: add modern client lifecycle modes (SEP-2575) (#995)

* feat: add modern client lifecycle modes

* refactor: drop modern naming and dead cleanup

* fix: gate request metadata on inline lifecycle only
This commit is contained in:
Dale Seo 2026-07-16 17:12:16 -04:00 • committed by GitHub
commit 2e2c79173c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1525 additions and 306 deletions

View file

@ -0,0 +1,312 @@
#![cfg(all(feature = "client", feature = "server", not(feature = "local")))]
use rmcp::{
ClientHandler, ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt,
model::{
ClientJsonRpcMessage, ClientRequest, DiscoverResult, ErrorCode, ErrorData, GetMeta,
Implementation, InitializeResult, ProtocolVersion, ServerCapabilities,
ServerJsonRpcMessage, ServerResult,
},
service::PeerRequestOptions,
transport::{IntoTransport, Transport},
};
#[derive(Clone, Default)]
struct DiscoverClient;
impl ClientHandler for DiscoverClient {}
#[derive(Clone, Default)]
struct StatelessServer;
impl ServerHandler for StatelessServer {}
#[tokio::test]
async fn high_level_server_accepts_discover_startup_without_initialize() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let server_task = tokio::spawn(async move {
StatelessServer
.serve(server_transport)
.await
.expect("server should accept discover")
});
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");
let server = server_task.await.expect("server task");
server.cancel().await.expect("cancel server");
}
#[tokio::test]
async fn discover_startup_omits_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(request) =
server.receive().await.expect("expected discover request")
else {
panic!("expected request");
};
assert!(matches!(request.request, ClientRequest::DiscoverRequest(_)));
let meta = request.request.get_meta();
assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28));
assert!(meta.client_info().is_some());
assert!(meta.client_capabilities().is_some());
server
.send(ServerJsonRpcMessage::response(
ServerResult::DiscoverResult(DiscoverResult::new(
vec![ProtocolVersion::V_2026_07_28],
ServerCapabilities::default(),
Implementation::new("discover-server", "1.0.0"),
)),
request.id,
))
.await
.expect("send discover response");
let ClientJsonRpcMessage::Request(request) =
server.receive().await.expect("expected normal request")
else {
panic!("expected request");
};
assert!(!matches!(
request.request,
ClientRequest::InitializeRequest(_)
));
let meta = request.request.get_meta();
assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2025_11_25));
assert!(meta.client_info().is_some());
assert!(meta.client_capabilities().is_some());
assert_eq!(
meta.get("example.test/extension"),
Some(&serde_json::json!(7))
);
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");
let mut caller_meta = rmcp::model::RequestMetaObject::new();
caller_meta.insert("example.test/extension".into(), serde_json::json!(7));
caller_meta.set_protocol_version(ProtocolVersion::V_2025_11_25);
client
.send_request_with_option(
ClientRequest::ListToolsRequest(rmcp::model::ListToolsRequest {
method: Default::default(),
params: None,
extensions: Default::default(),
}),
PeerRequestOptions::default().with_meta(caller_meta),
)
.await
.expect("send list tools")
.await_response()
.await
.expect("list tools response");
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);
let mut server = IntoTransport::<rmcp::RoleServer, _, _>::into_transport(server_transport);
let server_task = tokio::spawn(async move {
let ClientJsonRpcMessage::Request(discover) =
server.receive().await.expect("expected discover request")
else {
panic!("expected request");
};
assert!(matches!(
discover.request,
ClientRequest::DiscoverRequest(_)
));
server
.send(ServerJsonRpcMessage::error(
ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "Method not found", None),
Some(discover.id),
))
.await
.expect("send method-not-found");
let ClientJsonRpcMessage::Request(initialize) =
server.receive().await.expect("expected initialize request")
else {
panic!("expected request");
};
assert!(matches!(
initialize.request,
ClientRequest::InitializeRequest(_)
));
server
.send(ServerJsonRpcMessage::response(
ServerResult::InitializeResult(
InitializeResult::new(ServerCapabilities::default()),
),
initialize.id,
))
.await
.expect("send initialize response");
assert!(matches!(
server.receive().await,
Some(ClientJsonRpcMessage::Notification(_))
));
});
let client = DiscoverClient
.serve_with_lifecycle(
client_transport,
ClientLifecycleMode::Auto {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
legacy_version: Some(ProtocolVersion::V_2025_11_25),
},
)
.await
.expect("auto client should fall back");
client.cancel().await.expect("cancel client");
server_task.await.expect("server task");
}
#[tokio::test]
async fn discover_startup_retries_a_mutually_supported_version() {
let unsupported: ProtocolVersion =
serde_json::from_value(serde_json::json!("2099-01-01")).unwrap();
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(first) =
server.receive().await.expect("expected first discover")
else {
panic!("expected request");
};
assert_eq!(
first.request.get_meta().protocol_version(),
Some(unsupported.clone())
);
server
.send(ServerJsonRpcMessage::error(
ErrorData::unsupported_protocol_version(
unsupported,
&[ProtocolVersion::V_2026_07_28],
),
Some(first.id),
))
.await
.expect("send unsupported error");
let ClientJsonRpcMessage::Request(second) =
server.receive().await.expect("expected retry discover")
else {
panic!("expected request");
};
assert_eq!(
second.request.get_meta().protocol_version(),
Some(ProtocolVersion::V_2026_07_28)
);
server
.send(ServerJsonRpcMessage::response(
ServerResult::DiscoverResult(DiscoverResult::new(
vec![ProtocolVersion::V_2026_07_28],
ServerCapabilities::default(),
Implementation::new("discover-server", "1.0.0"),
)),
second.id,
))
.await
.expect("send discover response");
});
let client = DiscoverClient
.serve_with_lifecycle(
client_transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![
serde_json::from_value(serde_json::json!("2099-01-01")).unwrap(),
ProtocolVersion::V_2026_07_28,
],
},
)
.await
.expect("discover client should retry");
client.cancel().await.expect("cancel client");
server_task.await.expect("server task");
}
#[tokio::test]
async fn discover_startup_retries_current_version_once_when_server_reports_it_supported() {
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(first) =
server.receive().await.expect("expected first discover")
else {
panic!("expected request");
};
server
.send(ServerJsonRpcMessage::error(
ErrorData::unsupported_protocol_version(
ProtocolVersion::V_2026_07_28,
&[ProtocolVersion::V_2026_07_28],
),
Some(first.id),
))
.await
.expect("send unsupported error");
let ClientJsonRpcMessage::Request(second) =
server.receive().await.expect("expected retry discover")
else {
panic!("expected request");
};
assert_eq!(
second.request.get_meta().protocol_version(),
Some(ProtocolVersion::V_2026_07_28)
);
server
.send(ServerJsonRpcMessage::response(
ServerResult::DiscoverResult(DiscoverResult::new(
vec![ProtocolVersion::V_2026_07_28],
ServerCapabilities::default(),
Implementation::new("discover-server", "1.0.0"),
)),
second.id,
))
.await
.expect("send discover response");
});
let client = DiscoverClient
.serve_with_lifecycle(
client_transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await
.expect("discover client should retry once");
client.cancel().await.expect("cancel client");
server_task.await.expect("server task");
}

View file

@ -0,0 +1,137 @@
#![cfg(all(
not(feature = "local"),
feature = "client",
feature = "reqwest",
feature = "transport-streamable-http-server"
))]
use std::borrow::Cow;
use rmcp::{
ClientLifecycleMode, ClientServiceExt, ServerHandler,
model::{ClientInfo, DiscoverResult, ErrorCode, ErrorData, ProtocolVersion},
service::{MaybeSendFuture, RequestContext, RoleServer},
transport::{
StreamableHttpClientTransport,
streamable_http_client::StreamableHttpClientTransportConfig,
streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
},
};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Default)]
struct DiscoverHttpServer;
impl ServerHandler for DiscoverHttpServer {
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(&[ProtocolVersion::V_2026_07_28])
}
}
#[derive(Clone, Default)]
struct LegacyHttpServer;
impl ServerHandler for LegacyHttpServer {
fn discover(
&self,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<DiscoverResult, ErrorData>> + MaybeSendFuture + '_ {
std::future::ready(Err(ErrorData::new(
ErrorCode::METHOD_NOT_FOUND,
"Method not found",
None,
)))
}
}
#[tokio::test]
async fn discover_http_client_bootstraps_headers_without_initialize() {
let ct = CancellationToken::new();
let service: StreamableHttpService<DiscoverHttpServer, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(DiscoverHttpServer),
Default::default(),
StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_json_response(true)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener address");
let server = tokio::spawn({
let ct = ct.clone();
async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
.await;
}
});
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")),
);
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await
.expect("discover HTTP client should start");
client.list_tools(None).await.expect("list tools");
client.cancel().await.expect("cancel client");
ct.cancel();
server.await.expect("server task");
}
#[tokio::test]
async fn auto_http_client_falls_back_to_stateful_legacy_startup() {
let ct = CancellationToken::new();
let service: StreamableHttpService<LegacyHttpServer, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(LegacyHttpServer),
Default::default(),
StreamableHttpServerConfig::default()
.with_json_response(true)
.with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener address");
let server = tokio::spawn({
let ct = ct.clone();
async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
.await;
}
});
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(format!("http://{address}/mcp")),
);
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Auto {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
legacy_version: Some(ProtocolVersion::V_2025_11_25),
},
)
.await
.expect("auto HTTP client should fall back");
client.list_tools(None).await.expect("list tools");
client.cancel().await.expect("cancel client");
ct.cancel();
server.await.expect("server task");
}

View file

@ -30,6 +30,29 @@ fn meta_setters_store_sep_2575_values() {
assert_eq!(meta.get(META_KEY_LOG_LEVEL), Some(&json!("warning")));
}
#[test]
fn with_client_context_sets_all_required_sep_2575_fields() {
let meta = RequestMetaObject::with_client_context(
ProtocolVersion::V_2026_07_28,
Implementation::new("test-client", "1.0.0"),
ClientCapabilities::default(),
);
assert!(
meta.missing_required_keys(&ProtocolVersion::V_2026_07_28)
.is_empty()
);
assert_eq!(meta.protocol_version(), Some(ProtocolVersion::V_2026_07_28));
assert_eq!(
meta.client_info(),
Some(Implementation::new("test-client", "1.0.0"))
);
assert_eq!(
meta.client_capabilities(),
Some(ClientCapabilities::default())
);
}
#[test]
fn meta_accessors_decode_wire_values() {
let meta: RequestMetaObject = serde_json::from_value(json!({

View file

@ -0,0 +1,196 @@
#![cfg(all(feature = "server", not(feature = "local")))]
use std::sync::{Arc, Mutex};
use rmcp::{
ServerHandler, ServiceExt,
model::{
ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest,
DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest,
ListToolsResult, PaginatedRequestParams, ProtocolVersion, RequestId, RequestMetaObject,
ServerJsonRpcMessage,
},
service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError},
transport::{IntoTransport, Transport},
};
#[derive(Clone, Default)]
struct StatelessServer;
impl ServerHandler for StatelessServer {}
fn complete_meta() -> RequestMetaObject {
complete_meta_for("stateless-client")
}
fn complete_meta_for(client_name: &str) -> RequestMetaObject {
let mut meta = RequestMetaObject::new();
meta.set_protocol_version(ProtocolVersion::V_2026_07_28);
meta.set_client_info(Implementation::new(client_name, "1.0.0"));
meta.set_client_capabilities(ClientCapabilities::default());
meta
}
fn list_tools_request(meta: RequestMetaObject) -> ClientJsonRpcMessage {
let mut request = ListToolsRequest {
method: Default::default(),
params: None,
extensions: Default::default(),
};
request.extensions.insert(meta);
ClientJsonRpcMessage::request(
ClientRequest::ListToolsRequest(request),
RequestId::Number(1),
)
}
#[tokio::test]
async fn stateless_server_rejects_missing_metadata_on_every_request() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let server_task = tokio::spawn(async move {
StatelessServer
.serve(server_transport)
.await
.expect("server should start")
});
let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport);
let mut discover = DiscoverRequest::new(DiscoverRequestParams {});
discover.extensions.insert(complete_meta());
client
.send(ClientJsonRpcMessage::request(
ClientRequest::DiscoverRequest(discover),
RequestId::Number(1),
))
.await
.expect("send discover");
assert!(matches!(
client.receive().await,
Some(ServerJsonRpcMessage::Response(_))
));
client
.send(ClientJsonRpcMessage::request(
ClientRequest::ListToolsRequest(ListToolsRequest {
method: Default::default(),
params: None,
extensions: Default::default(),
}),
RequestId::Number(2),
))
.await
.expect("send list tools");
let Some(ServerJsonRpcMessage::Error(error)) = client.receive().await else {
panic!("expected invalid params");
};
assert_eq!(error.error.code, ErrorCode::INVALID_PARAMS);
server_task
.await
.expect("server task")
.cancel()
.await
.expect("cancel server");
}
#[derive(Clone)]
struct ContextServer {
seen_clients: Arc<Mutex<Vec<String>>>,
}
impl ServerHandler for ContextServer {
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, ErrorData>> + MaybeSendFuture + '_ {
let seen_clients = self.seen_clients.clone();
async move {
seen_clients
.lock()
.expect("seen clients lock")
.push(context.client_info().expect("current client info").name);
Ok(ListToolsResult::default())
}
}
}
#[tokio::test]
async fn stateless_server_uses_each_requests_client_context() {
let seen_clients = Arc::new(Mutex::new(Vec::new()));
let handler = ContextServer {
seen_clients: seen_clients.clone(),
};
let (server_transport, client_transport) = tokio::io::duplex(4096);
let server_task = tokio::spawn(async move {
handler
.serve(server_transport)
.await
.expect("server should start")
});
let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport);
client
.send(list_tools_request(complete_meta_for("first-client")))
.await
.expect("send first request");
assert!(matches!(
client.receive().await,
Some(ServerJsonRpcMessage::Response(_))
));
let mut second = list_tools_request(complete_meta_for("second-client"));
if let ClientJsonRpcMessage::Request(request) = &mut second {
request.id = RequestId::Number(2);
}
client.send(second).await.expect("send second request");
assert!(matches!(
client.receive().await,
Some(ServerJsonRpcMessage::Response(_))
));
assert_eq!(
*seen_clients.lock().expect("seen clients lock"),
["first-client", "second-client"]
);
server_task
.await
.expect("server task")
.cancel()
.await
.expect("cancel server");
}
#[tokio::test]
async fn stateless_server_rejects_malformed_metadata_opener() {
let (server_transport, client_transport) = tokio::io::duplex(4096);
let server_task = tokio::spawn(async move { StatelessServer.serve(server_transport).await });
let mut client = IntoTransport::<rmcp::RoleClient, _, _>::into_transport(client_transport);
let mut request = ListToolsRequest {
method: Default::default(),
params: None,
extensions: Default::default(),
};
let malformed: RequestMetaObject = serde_json::from_value(serde_json::json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": "wrong",
"io.modelcontextprotocol/clientCapabilities": null
}))
.unwrap();
request.extensions.insert(malformed);
client
.send(ClientJsonRpcMessage::request(
ClientRequest::ListToolsRequest(request),
RequestId::Number(1),
))
.await
.expect("send list tools");
let Err(error) = server_task.await.expect("server task") else {
panic!("malformed opener should not start a session");
};
assert!(matches!(
error,
ServerInitializeError::ExpectedInitializeRequest(Some(_))
));
}

View file

@ -16,7 +16,7 @@ use rmcp::{
ServiceError, ServiceExt,
model::{
CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode,
ErrorData, InitializeResult, PingRequest, RequestId, ServerCapabilities,
ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities,
ServerJsonRpcMessage, ServerResult,
},
transport::{
@ -90,7 +90,7 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
message: ClientJsonRpcMessage,
_session_id: Option<Arc<str>>,
_auth_header: Option<String>,
_custom_headers: HashMap<HeaderName, HeaderValue>,
custom_headers: HashMap<HeaderName, HeaderValue>,
) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
let mut state = self.state.lock().await;
match state
@ -100,15 +100,23 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
{
MockPost::Initialize => {
state.session_counter += 1;
let protocol_version = if state.session_counter == 1 {
ProtocolVersion::V_2025_11_25
} else {
ProtocolVersion::V_2025_06_18
};
let id = match message {
ClientJsonRpcMessage::Request(request) => request.id,
other => panic!("expected initialize request, got {other:?}"),
};
Ok(StreamableHttpPostResponse::Json(
ServerJsonRpcMessage::response(
ServerResult::InitializeResult(InitializeResult::new(
ServerCapabilities::builder().enable_tools().build(),
)),
ServerResult::InitializeResult(
InitializeResult::new(
ServerCapabilities::builder().enable_tools().build(),
)
.with_protocol_version(protocol_version),
),
id,
),
Some(format!("session-{}", state.session_counter)),
@ -123,6 +131,12 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
}
MockPost::Accepted => {
if state.posts.is_empty() {
assert_eq!(
custom_headers
.get(&HeaderName::from_static("mcp-protocol-version"))
.and_then(|value| value.to_str().ok()),
Some(ProtocolVersion::V_2025_06_18.as_str())
);
self.final_retry_accepted.add_permits(1);
} else {
self.initial_request_accepted.add_permits(1);