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
2 changed files with 1525 additions and 306 deletions

View file

@ -54,6 +54,17 @@ pub enum ClientInitializeError {
#[error("JSON-RPC error: {0}")]
JsonRpcError(ErrorData),
#[error(
"no compatible protocol version (client: {client_supported:?}, server: {server_supported:?})"
)]
NoCompatibleProtocolVersion {
client_supported: Vec<ProtocolVersion>,
server_supported: Vec<ProtocolVersion>,
},
#[error("discover startup requires at least one preferred protocol version")]
NoPreferredProtocolVersion,
#[error("Cancelled")]
Cancelled,
}
@ -177,6 +188,43 @@ impl ServiceRole for RoleClient {
pub type ServerSink = Peer<RoleClient>;
/// Selects how a client establishes its MCP lifecycle.
///
/// Existing [`ServiceExt::serve`] behavior remains legacy initialization.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClientLifecycleMode {
/// Use the legacy `initialize` / `notifications/initialized` handshake.
Initialize,
/// Use `server/discover` and send self-contained per-request metadata.
Discover {
preferred_versions: Vec<ProtocolVersion>,
},
/// Probe with `server/discover`, falling back only when the peer proves it is legacy.
Auto {
preferred_versions: Vec<ProtocolVersion>,
legacy_version: Option<ProtocolVersion>,
},
}
/// Client-specific lifecycle entry points.
pub trait ClientServiceExt: Service<RoleClient> + Sized {
fn serve_with_lifecycle<T, E, A>(
self,
transport: T,
lifecycle: ClientLifecycleMode,
) -> impl Future<Output = Result<RunningService<RoleClient, Self>, ClientInitializeError>>
+ MaybeSendFuture
where
T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
serve_client_with_lifecycle(self, transport, lifecycle)
}
}
impl<S: Service<RoleClient>> ClientServiceExt for S {}
impl<S: Service<RoleClient>> ServiceExt<RoleClient> for S {
fn serve_with_ct<T, E, A>(
self,
@ -202,7 +250,13 @@ where
T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
serve_client_with_ct(service, transport, Default::default()).await
serve_client_with_lifecycle_and_ct(
service,
transport,
ClientLifecycleMode::Initialize,
Default::default(),
)
.await
}
pub async fn serve_client_with_ct<S, T, E, A>(
@ -210,13 +264,41 @@ pub async fn serve_client_with_ct<S, T, E, A>(
transport: T,
ct: CancellationToken,
) -> Result<RunningService<RoleClient, S>, ClientInitializeError>
where
S: Service<RoleClient>,
T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
serve_client_with_lifecycle_and_ct(service, transport, ClientLifecycleMode::Initialize, ct)
.await
}
pub async fn serve_client_with_lifecycle<S, T, E, A>(
service: S,
transport: T,
lifecycle: ClientLifecycleMode,
) -> Result<RunningService<RoleClient, S>, ClientInitializeError>
where
S: Service<RoleClient>,
T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
serve_client_with_lifecycle_and_ct(service, transport, lifecycle, Default::default()).await
}
pub async fn serve_client_with_lifecycle_and_ct<S, T, E, A>(
service: S,
transport: T,
lifecycle: ClientLifecycleMode,
ct: CancellationToken,
) -> Result<RunningService<RoleClient, S>, ClientInitializeError>
where
S: Service<RoleClient>,
T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
tokio::select! {
result = serve_client_with_ct_inner(service, transport.into_transport(), ct.clone()) => { result }
result = serve_client_with_ct_inner(service, transport.into_transport(), lifecycle, ct.clone()) => { result }
_ = ct.cancelled() => {
Err(ClientInitializeError::Cancelled)
}
@ -226,6 +308,7 @@ where
async fn serve_client_with_ct_inner<S, T>(
service: S,
transport: T,
lifecycle: ClientLifecycleMode,
ct: CancellationToken,
) -> Result<RunningService<RoleClient, S>, ClientInitializeError>
where
@ -234,12 +317,71 @@ where
{
let mut transport = transport.into_transport();
let id_provider = <Arc<AtomicU32RequestIdProvider>>::default();
let (peer, peer_rx) = Peer::new(id_provider.clone(), None);
let client_info = service.get_info();
// service
match lifecycle {
ClientLifecycleMode::Initialize => {
legacy_startup(&service, &mut transport, &id_provider, &peer, client_info).await?;
}
ClientLifecycleMode::Discover { preferred_versions } => {
discover_startup(
&service,
&mut transport,
&id_provider,
&peer,
&client_info,
preferred_versions,
)
.await?;
}
ClientLifecycleMode::Auto {
preferred_versions,
legacy_version,
} => {
let discover_result = discover_startup(
&service,
&mut transport,
&id_provider,
&peer,
&client_info,
preferred_versions,
)
.await;
match discover_result {
Ok(()) => {}
Err(ClientInitializeError::JsonRpcError(error))
if error.code == crate::model::ErrorCode::METHOD_NOT_FOUND =>
{
let mut legacy_info = client_info;
if let Some(version) = legacy_version {
legacy_info.protocol_version = version;
}
legacy_startup(&service, &mut transport, &id_provider, &peer, legacy_info)
.await?;
}
Err(error) => return Err(error),
}
}
}
Ok(serve_inner(service, transport, peer, peer_rx, ct))
}
async fn legacy_startup<S, T>(
service: &S,
transport: &mut T,
id_provider: &Arc<AtomicU32RequestIdProvider>,
peer: &Peer<RoleClient>,
client_info: ClientInfo,
) -> Result<(), ClientInitializeError>
where
S: Service<RoleClient>,
T: Transport<RoleClient> + 'static,
{
let id = id_provider.next_request_id();
let init_request = InitializeRequest {
method: Default::default(),
params: service.get_info(),
params: client_info,
extensions: Default::default(),
};
transport
@ -253,15 +395,8 @@ where
context: "send initialize request".into(),
})?;
let (peer, peer_rx) = Peer::new(id_provider, None);
let (response, response_id) = expect_response(
&mut transport,
"initialize response",
&service,
peer.clone(),
)
.await?;
let (response, response_id) =
expect_response(transport, "initialize response", service, peer.clone()).await?;
if id != response_id {
return Err(ClientInitializeError::ConflictInitResponseId(
@ -285,7 +420,115 @@ where
transport.send(notification).await.map_err(|error| {
ClientInitializeError::transport::<T>(error, "send initialized notification")
})?;
Ok(serve_inner(service, transport, peer, peer_rx, ct))
Ok(())
}
async fn discover_startup<S, T>(
service: &S,
transport: &mut T,
id_provider: &Arc<AtomicU32RequestIdProvider>,
peer: &Peer<RoleClient>,
client_info: &ClientInfo,
preferred_versions: Vec<ProtocolVersion>,
) -> Result<(), ClientInitializeError>
where
S: Service<RoleClient>,
T: Transport<RoleClient> + 'static,
{
if preferred_versions.is_empty() {
return Err(ClientInitializeError::NoPreferredProtocolVersion);
}
let mut attempted = Vec::new();
let mut candidate = preferred_versions[0].clone();
loop {
attempted.push(candidate.clone());
let meta = RequestMetaObject::with_client_context(
candidate.clone(),
client_info.client_info.clone(),
client_info.capabilities.clone(),
);
let mut discover = DiscoverRequest::new(DiscoverRequestParams {});
discover.extensions.insert(meta);
let id = id_provider.next_request_id();
transport
.send(ClientJsonRpcMessage::request(
ClientRequest::DiscoverRequest(discover),
id.clone(),
))
.await
.map_err(|error| {
ClientInitializeError::transport::<T>(error, "send discover request")
})?;
match expect_response(transport, "discover response", service, peer.clone()).await {
Ok((ServerResult::DiscoverResult(result), response_id)) => {
if response_id != id {
return Err(ClientInitializeError::ConflictInitResponseId(
id,
response_id,
));
}
let Some(selected) =
select_protocol_version(&preferred_versions, &result.supported_versions)
else {
return Err(ClientInitializeError::NoCompatibleProtocolVersion {
client_supported: preferred_versions,
server_supported: result.supported_versions,
});
};
peer.set_peer_info(ServerInfo {
protocol_version: selected.clone(),
capabilities: result.capabilities,
server_info: result.server_info,
instructions: result.instructions,
meta: result.meta,
});
peer.set_client_request_metadata(ClientRequestMetadata {
protocol_version: selected,
client_info: client_info.client_info.clone(),
client_capabilities: client_info.capabilities.clone(),
});
return Ok(());
}
Ok((response, _)) => {
return Err(ClientInitializeError::ExpectedInitResult(Some(response)));
}
Err(ClientInitializeError::JsonRpcError(error))
if error.code == crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION =>
{
let supported = error
.data
.as_ref()
.and_then(|data| data.get("supported"))
.cloned()
.and_then(|value| serde_json::from_value::<Vec<ProtocolVersion>>(value).ok())
.unwrap_or_default();
let may_retry_current = attempted
.iter()
.filter(|version| *version == &candidate)
.count()
== 1;
let next = preferred_versions
.iter()
.find(|version| {
supported.contains(version)
&& (!attempted.contains(version)
|| (may_retry_current && *version == &candidate))
})
.cloned();
let Some(next) = next else {
return Err(ClientInitializeError::NoCompatibleProtocolVersion {
client_supported: preferred_versions,
server_supported: supported,
});
};
candidate = next;
}
Err(error) => return Err(error),
}
}
}
macro_rules! method {

View file

@ -217,12 +217,41 @@ where
}
};
let ClientRequest::InitializeRequest(peer_info) = &request else {
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
ClientJsonRpcMessage::request(request, id),
)));
let initialize_request = match request {
ClientRequest::InitializeRequest(request) => request,
mut request => {
if !request
.get_meta()
.missing_required_keys(&ProtocolVersion::V_2026_07_28)
.is_empty()
{
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
ClientJsonRpcMessage::request(request, id),
)));
}
let (peer, peer_rx) = Peer::new(id_provider, None);
peer.require_request_metadata();
let context = RequestContext {
ct: ct.child_token(),
id: id.clone(),
meta: std::mem::take(request.get_meta_mut()),
extensions: std::mem::take(request.extensions_mut()),
peer: peer.clone(),
};
let response = match service.handle_request(request, context).await {
Ok(result) => ServerJsonRpcMessage::response(result, id),
Err(error) => ServerJsonRpcMessage::error(error, Some(id)),
};
transport.send(response).await.map_err(|error| {
ServerInitializeError::transport::<T>(error, "sending negotiated request response")
})?;
return Ok(serve_inner(service, transport, peer, peer_rx, ct));
}
};
let (peer, peer_rx) = Peer::new(id_provider, Some(peer_info.params.clone()));
let requested_protocol_version = initialize_request.params.protocol_version.clone();
let mut negotiated_peer_info = initialize_request.params.clone();
let (peer, peer_rx) = Peer::new(id_provider, Some(negotiated_peer_info.clone()));
let request = ClientRequest::InitializeRequest(initialize_request);
let context = RequestContext {
ct: ct.child_token(),
id: id.clone(),
@ -231,7 +260,7 @@ where
peer: peer.clone(),
};
// Send initialize response
let init_response = service.handle_request(request.clone(), context).await;
let init_response = service.handle_request(request, context).await;
let mut init_response = match init_response {
Ok(ServerResult::InitializeResult(init_response)) => init_response,
Ok(result) => {
@ -247,13 +276,10 @@ where
return Err(ServerInitializeError::InitializeFailed(e));
}
};
init_response.protocol_version = negotiate_protocol_version(
&peer_info.params.protocol_version,
init_response.protocol_version,
);
init_response.protocol_version =
negotiate_protocol_version(&requested_protocol_version, init_response.protocol_version);
// Update peer_info so context.protocol_version() reflects the negotiated
// version in all subsequent request handlers.
let mut negotiated_peer_info = peer_info.params.clone();
negotiated_peer_info.protocol_version = init_response.protocol_version.clone();
peer.set_peer_info(negotiated_peer_info);
transport