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

View file

@ -226,6 +226,14 @@ jobs:
--scenario sep-2322-client-request-state \ --scenario sep-2322-client-request-state \
-o conformance-client-results/mrtr -o conformance-client-results/mrtr
- name: Run draft SEP-2575 client scenario
run: |
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \
--command "$(pwd)/target/debug/conformance-client" \
--scenario request-metadata \
--spec-version draft \
-o conformance-client-results/sep-2575
- name: Upload results - name: Upload results
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7

View file

@ -74,6 +74,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
``` ```
</details> </details>
### Client lifecycle modes
`serve()` uses the legacy MCP lifecycle: the client sends `initialize`, receives
the negotiated server information, and then sends `notifications/initialized`.
Use [`ClientServiceExt::serve_with_lifecycle`](crates/rmcp/src/service/client.rs) to
select another lifecycle explicitly:
```rust, ignore
use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion};
// Start directly with server/discover and include client metadata on every request.
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
// Or probe the discover lifecycle and fall back when a legacy server reports
// that server/discover is not implemented.
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?;
```
`ClientLifecycleMode::Initialize` is equivalent to the existing `serve()` behavior.
Discover startup does not send `notifications/initialized`; discovery completes
startup, and each subsequent request carries its protocol version, client
information, and capabilities in `_meta`.
### Build a Server ### Build a Server
<details> <details>
@ -812,10 +850,11 @@ impl ServerHandler for MyServer {
### Initialized notification ### Initialized notification
Clients send `initialized` after the handshake completes: Legacy clients send `initialized` after the `initialize` handshake completes.
Clients using `ClientLifecycleMode::Discover` do not send this notification:
```rust ```rust
// Sent automatically by rmcp during the serve() handshake. // Sent automatically by rmcp during the legacy serve() handshake.
// Servers handle it via: // Servers handle it via:
impl ServerHandler for MyServer { impl ServerHandler for MyServer {
async fn on_initialized( async fn on_initialized(

View file

@ -1,7 +1,7 @@
use rmcp::{ use rmcp::{
ClientHandler, ErrorData, RoleClient, ServiceExt, ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt,
model::*, model::*,
service::{RequestContext, serve_directly}, service::RequestContext,
transport::{ transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport, AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{AuthorizationCallback, OAuthState}, auth::{AuthorizationCallback, OAuthState},
@ -846,33 +846,6 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
Ok(()) Ok(())
} }
/// A minimal stateless client transport: every outgoing message is one HTTP
/// POST and the JSON response body (if any) is queued for `receive()`.
///
/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle
/// (no `initialize` handshake, plain JSON responses), which the session-based
/// `StreamableHttpClientTransport` cannot do. The transport is harness
/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs
/// unchanged on top of it.
struct StatelessHttpTransport {
http: reqwest::Client,
uri: std::sync::Arc<str>,
tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
rx: tokio::sync::mpsc::Receiver<ServerJsonRpcMessage>,
}
impl StatelessHttpTransport {
fn new(uri: &str) -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(16);
Self {
http: reqwest::Client::new(),
uri: uri.into(),
tx,
rx,
}
}
}
fn conformance_protocol_version() -> ProtocolVersion { fn conformance_protocol_version() -> ProtocolVersion {
std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION") std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION")
.ok() .ok()
@ -880,62 +853,22 @@ fn conformance_protocol_version() -> ProtocolVersion {
.unwrap_or(ProtocolVersion::V_2026_07_28) .unwrap_or(ProtocolVersion::V_2026_07_28)
} }
impl rmcp::transport::Transport<RoleClient> for StatelessHttpTransport { /// Runs draft stateless scenarios through the public discover lifecycle and
type Error = std::io::Error; /// Streamable HTTP transport.
async fn run_discover_client(server_url: &str) -> anyhow::Result<()> {
fn send( let mut preferred_versions = vec![conformance_protocol_version()];
&mut self, for version in ProtocolVersion::KNOWN_VERSIONS.iter().rev() {
item: rmcp::model::ClientJsonRpcMessage, if !preferred_versions.contains(version) {
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static { preferred_versions.push(version.clone());
let http = self.http.clone();
let uri = self.uri.clone();
let tx = self.tx.clone();
async move {
let response = http
.post(uri.as_ref())
.header(
"MCP-Protocol-Version",
conformance_protocol_version().as_str(),
)
.json(&item)
.send()
.await
.map_err(std::io::Error::other)?;
match response.json::<ServerJsonRpcMessage>().await {
Ok(message) => {
let _ = tx.send(message).await;
}
Err(_) => {
// No JSON-RPC body (e.g. 202/204 for notifications).
}
}
Ok(())
} }
} }
let transport = StreamableHttpClientTransport::from_uri(server_url);
async fn receive(&mut self) -> Option<ServerJsonRpcMessage> { let client = FullClientHandler
self.rx.recv().await .serve_with_lifecycle(
} transport,
ClientLifecycleMode::Discover { preferred_versions },
async fn close(&mut self) -> Result<(), Self::Error> { )
Ok(()) .await?;
}
}
/// Runs a client using the draft stateless lifecycle.
///
/// Stateless servers do not implement the `initialize` handshake, so this
/// uses `serve_directly`. The protocol version comes from
/// `MCP_CONFORMANCE_PROTOCOL_VERSION` (defaulting to `2026-07-28`) and is
/// used for both peer configuration and outgoing HTTP request headers.
///
/// Lists available tools and calls each one, allowing the SDK's high-level
/// tool-call handling to process any request retries.
async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> {
let transport = StatelessHttpTransport::new(server_url);
let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(conformance_protocol_version());
let client = serve_directly(FullClientHandler, transport, Some(peer_info));
let tools = client.list_tools(Default::default()).await?; let tools = client.list_tools(Default::default()).await?;
tracing::debug!("Listed {} tools", tools.tools.len()); tracing::debug!("Listed {} tools", tools.tools.len());
@ -989,14 +922,14 @@ async fn main() -> anyhow::Result<()> {
match scenario.as_str() { match scenario.as_str() {
// Non-auth scenarios // Non-auth scenarios
"initialize" => run_basic_client(&server_url).await?, "initialize" => run_basic_client(&server_url).await?,
"json-schema-ref-no-deref" => run_stateless_client(&server_url).await?, "json-schema-ref-no-deref" => run_discover_client(&server_url).await?,
"tools_call" => run_tools_call_client(&server_url, &ctx).await?, "tools_call" => run_tools_call_client(&server_url, &ctx).await?,
"elicitation-sep1034-client-defaults" => { "elicitation-sep1034-client-defaults" => {
run_elicitation_defaults_client(&server_url).await? run_elicitation_defaults_client(&server_url).await?
} }
"sse-retry" => run_sse_retry_client(&server_url).await?, "sse-retry" => run_sse_retry_client(&server_url).await?,
"request-metadata" | "sep-2322-client-request-state" => { "request-metadata" | "sep-2322-client-request-state" => {
run_stateless_client(&server_url).await? run_discover_client(&server_url).await?
} }
"http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => { "http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => {
run_tools_call_client(&server_url, &ctx).await? run_tools_call_client(&server_url, &ctx).await?

View file

@ -302,6 +302,26 @@ name = "test_protocol_version_negotiation"
required-features = ["server", "client"] required-features = ["server", "client"]
path = "tests/test_protocol_version_negotiation.rs" path = "tests/test_protocol_version_negotiation.rs"
[[test]]
name = "test_client_lifecycle_modes"
required-features = ["client", "server"]
path = "tests/test_client_lifecycle_modes.rs"
[[test]]
name = "test_stateless_server_requests"
required-features = ["client", "server"]
path = "tests/test_stateless_server_requests.rs"
[[test]]
name = "test_discover_http_client_startup"
required-features = [
"client",
"reqwest",
"transport-streamable-http-client-reqwest",
"transport-streamable-http-server",
]
path = "tests/test_discover_http_client_startup.rs"
[[test]] [[test]]
name = "test_streamable_http_standard_headers" name = "test_streamable_http_standard_headers"
required-features = ["server", "client", "transport-streamable-http-server", "reqwest"] required-features = ["server", "client", "transport-streamable-http-server", "reqwest"]

View file

@ -41,22 +41,30 @@ impl<H: ServerHandler> Service<RoleServer> for H {
)); ));
} }
} }
if matches!(&request, ClientRequest::DiscoverRequest(_)) { // Self-contained metadata is required only when the request itself uses
if requested_version.is_none() { // the inline lifecycle: a discover opener, a session that started without
// `initialize`, or a request that declares 2026-07-28+ in its own _meta.
// Sessions that negotiated via `initialize` (or `serve_directly`) keep the
// session model and may omit per-request metadata.
let requires_request_metadata = uses_inline_negotiation
&& (matches!(&request, ClientRequest::DiscoverRequest(_))
|| context.peer.request_metadata_required()
|| requested_version.as_ref().is_some_and(|version| {
version.as_str() >= ProtocolVersion::V_2026_07_28.as_str()
}));
if requires_request_metadata {
// Inline lifecycle requests are defined by the 2026-07-28 protocol.
// Validate that lifecycle contract even when a request selects an
// older application protocol version.
let missing = context
.meta
.missing_required_keys(&ProtocolVersion::V_2026_07_28);
if !missing.is_empty() {
return Err(McpError::invalid_params( return Err(McpError::invalid_params(
"server/discover requires protocolVersion in request _meta", format!(
None, "request _meta is missing or has malformed required fields: {}",
)); missing.join(", ")
} ),
if context.meta.client_info().is_none() {
return Err(McpError::invalid_params(
"server/discover requires clientInfo in request _meta",
None,
));
}
if context.meta.client_capabilities().is_none() {
return Err(McpError::invalid_params(
"server/discover requires clientCapabilities in request _meta",
None, None,
)); ));
} }

View file

@ -16,10 +16,13 @@ pub use handler::client::ClientHandler;
pub use handler::server::ServerHandler; pub use handler::server::ServerHandler;
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub use handler::server::wrapper::Json; pub use handler::server::wrapper::Json;
#[cfg(feature = "client")]
pub use service::{
ClientLifecycleMode, ClientServiceExt, RoleClient, select_protocol_version, serve_client,
serve_client_with_lifecycle,
};
#[cfg(any(feature = "client", feature = "server"))] #[cfg(any(feature = "client", feature = "server"))]
pub use service::{Peer, Service, ServiceError, ServiceExt}; pub use service::{Peer, Service, ServiceError, ServiceExt};
#[cfg(feature = "client")]
pub use service::{RoleClient, select_protocol_version, serve_client};
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub use service::{RoleServer, serve_server}; pub use service::{RoleServer, serve_server};

View file

@ -439,6 +439,19 @@ impl RequestMetaObject {
meta meta
} }
/// Create request metadata with the client context SEP-2575 requires on every request.
pub fn with_client_context(
protocol_version: ProtocolVersion,
client_info: Implementation,
client_capabilities: ClientCapabilities,
) -> Self {
let mut meta = Self::new();
meta.set_protocol_version(protocol_version);
meta.set_client_info(client_info);
meta.set_client_capabilities(client_capabilities);
meta
}
pub(crate) fn static_empty() -> &'static Self { pub(crate) fn static_empty() -> &'static Self {
static EMPTY: std::sync::OnceLock<RequestMetaObject> = std::sync::OnceLock::new(); static EMPTY: std::sync::OnceLock<RequestMetaObject> = std::sync::OnceLock::new();
EMPTY.get_or_init(Default::default) EMPTY.get_or_init(Default::default)

View file

@ -1,3 +1,5 @@
use std::sync::OnceLock;
use futures::FutureExt; use futures::FutureExt;
#[cfg(not(feature = "local"))] #[cfg(not(feature = "local"))]
use futures::future::BoxFuture; use futures::future::BoxFuture;
@ -51,9 +53,10 @@ use crate::model::ServerNotification;
use crate::{ use crate::{
error::ErrorData as McpError, error::ErrorData as McpError,
model::{ model::{
CancelledNotification, CancelledNotificationParam, Extensions, GetExtensions, GetMeta, CancelledNotification, CancelledNotificationParam, ClientCapabilities, Extensions,
JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, GetExtensions, GetMeta, Implementation, JsonRpcError, JsonRpcMessage, JsonRpcNotification,
NotificationMetaObject, NumberOrString, ProgressToken, RequestId, RequestMetaObject, JsonRpcRequest, JsonRpcResponse, NotificationMetaObject, NumberOrString, ProgressToken,
ProtocolVersion, RequestId, RequestMetaObject,
}, },
transport::{DynamicTransportError, IntoTransport, Transport}, transport::{DynamicTransportError, IntoTransport, Transport},
}; };
@ -515,6 +518,13 @@ pub(crate) enum PeerSinkMessage<R: ServiceRole> {
}, },
} }
#[derive(Debug, Clone)]
pub(crate) struct ClientRequestMetadata {
pub protocol_version: ProtocolVersion,
pub client_info: Implementation,
pub client_capabilities: ClientCapabilities,
}
/// An interface to fetch the remote client or server /// An interface to fetch the remote client or server
/// ///
/// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer. /// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer.
@ -527,6 +537,8 @@ pub struct Peer<R: ServiceRole> {
progress_token_provider: Arc<dyn ProgressTokenProvider>, progress_token_provider: Arc<dyn ProgressTokenProvider>,
progress_timeout_watchers: ProgressTimeoutWatchers, progress_timeout_watchers: ProgressTimeoutWatchers,
info: Arc<std::sync::RwLock<Option<Arc<R::PeerInfo>>>>, info: Arc<std::sync::RwLock<Option<Arc<R::PeerInfo>>>>,
client_request_metadata: Arc<OnceLock<ClientRequestMetadata>>,
request_metadata_required: Arc<std::sync::atomic::AtomicBool>,
} }
impl<R: ServiceRole> std::fmt::Debug for Peer<R> { impl<R: ServiceRole> std::fmt::Debug for Peer<R> {
@ -563,6 +575,14 @@ impl PeerRequestOptions {
} }
} }
/// Adds request metadata while preserving any other configured options.
///
/// Explicit values take precedence over discover-lifecycle metadata defaults.
pub fn with_meta(mut self, meta: RequestMetaObject) -> Self {
self.meta = Some(meta);
self
}
pub fn reset_timeout_on_progress(mut self) -> Self { pub fn reset_timeout_on_progress(mut self) -> Self {
self.reset_timeout_on_progress = true; self.reset_timeout_on_progress = true;
self self
@ -588,6 +608,8 @@ impl<R: ServiceRole> Peer<R> {
progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()), progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()),
progress_timeout_watchers: Default::default(), progress_timeout_watchers: Default::default(),
info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))), info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))),
client_request_metadata: Default::default(),
request_metadata_required: Default::default(),
}, },
rx, rx,
) )
@ -625,6 +647,12 @@ impl<R: ServiceRole> Peer<R> {
) -> Result<RequestHandle<R>, ServiceError> { ) -> Result<RequestHandle<R>, ServiceError> {
let id = self.request_id_provider.next_request_id(); let id = self.request_id_provider.next_request_id();
let progress_token = self.progress_token_provider.next_progress_token(); let progress_token = self.progress_token_provider.next_progress_token();
if let Some(metadata) = self.client_request_metadata.get() {
let meta = request.get_meta_mut();
meta.set_protocol_version(metadata.protocol_version.clone());
meta.set_client_info(metadata.client_info.clone());
meta.set_client_capabilities(metadata.client_capabilities.clone());
}
if let Some(meta) = options.meta.clone() { if let Some(meta) = options.meta.clone() {
request.get_meta_mut().extend(meta); request.get_meta_mut().extend(meta);
} }
@ -703,6 +731,21 @@ impl<R: ServiceRole> Peer<R> {
*self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info)); *self.info.write().expect("peer info lock poisoned") = Some(Arc::new(info));
} }
pub(crate) fn set_client_request_metadata(&self, metadata: ClientRequestMetadata) {
let result = self.client_request_metadata.set(metadata);
debug_assert!(result.is_ok(), "client request metadata set more than once");
}
pub(crate) fn require_request_metadata(&self) {
self.request_metadata_required
.store(true, std::sync::atomic::Ordering::Release);
}
pub(crate) fn request_metadata_required(&self) -> bool {
self.request_metadata_required
.load(std::sync::atomic::Ordering::Acquire)
}
pub fn is_transport_closed(&self) -> bool { pub fn is_transport_closed(&self) -> bool {
self.tx.is_closed() self.tx.is_closed()
} }
@ -883,11 +926,35 @@ impl<R: ServiceRole> RequestContext<R> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
impl RequestContext<RoleServer> { impl RequestContext<RoleServer> {
/// The protocol version the client negotiated, or `None` before peer info is recorded. /// The current request's protocol version, falling back to legacy handshake state.
pub fn protocol_version(&self) -> Option<crate::model::ProtocolVersion> { pub fn protocol_version(&self) -> Option<crate::model::ProtocolVersion> {
self.peer self.meta.protocol_version().or_else(|| {
.peer_info() self.peer
.map(|info| info.protocol_version.clone()) .peer_info()
.map(|info| info.protocol_version.clone())
})
}
/// The current request's client implementation, falling back only for legacy sessions.
pub fn client_info(&self) -> Option<Implementation> {
if self.peer.request_metadata_required() {
self.meta.client_info()
} else {
self.meta
.client_info()
.or_else(|| self.peer.peer_info().map(|info| info.client_info.clone()))
}
}
/// The current request's client capabilities, falling back only for legacy sessions.
pub fn client_capabilities(&self) -> Option<ClientCapabilities> {
if self.peer.request_metadata_required() {
self.meta.client_capabilities()
} else {
self.meta
.client_capabilities()
.or_else(|| self.peer.peer_info().map(|info| info.capabilities.clone()))
}
} }
} }

View file

@ -54,6 +54,17 @@ pub enum ClientInitializeError {
#[error("JSON-RPC error: {0}")] #[error("JSON-RPC error: {0}")]
JsonRpcError(ErrorData), 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")] #[error("Cancelled")]
Cancelled, Cancelled,
} }
@ -177,6 +188,43 @@ impl ServiceRole for RoleClient {
pub type ServerSink = Peer<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 { impl<S: Service<RoleClient>> ServiceExt<RoleClient> for S {
fn serve_with_ct<T, E, A>( fn serve_with_ct<T, E, A>(
self, self,
@ -202,7 +250,13 @@ where
T: IntoTransport<RoleClient, E, A>, T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static, 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>( 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, transport: T,
ct: CancellationToken, ct: CancellationToken,
) -> Result<RunningService<RoleClient, S>, ClientInitializeError> ) -> 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 where
S: Service<RoleClient>, S: Service<RoleClient>,
T: IntoTransport<RoleClient, E, A>, T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static, E: std::error::Error + Send + Sync + 'static,
{ {
tokio::select! { 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() => { _ = ct.cancelled() => {
Err(ClientInitializeError::Cancelled) Err(ClientInitializeError::Cancelled)
} }
@ -226,6 +308,7 @@ where
async fn serve_client_with_ct_inner<S, T>( async fn serve_client_with_ct_inner<S, T>(
service: S, service: S,
transport: T, transport: T,
lifecycle: ClientLifecycleMode,
ct: CancellationToken, ct: CancellationToken,
) -> Result<RunningService<RoleClient, S>, ClientInitializeError> ) -> Result<RunningService<RoleClient, S>, ClientInitializeError>
where where
@ -234,12 +317,71 @@ where
{ {
let mut transport = transport.into_transport(); let mut transport = transport.into_transport();
let id_provider = <Arc<AtomicU32RequestIdProvider>>::default(); 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 id = id_provider.next_request_id();
let init_request = InitializeRequest { let init_request = InitializeRequest {
method: Default::default(), method: Default::default(),
params: service.get_info(), params: client_info,
extensions: Default::default(), extensions: Default::default(),
}; };
transport transport
@ -253,15 +395,8 @@ where
context: "send initialize request".into(), context: "send initialize request".into(),
})?; })?;
let (peer, peer_rx) = Peer::new(id_provider, None); let (response, response_id) =
expect_response(transport, "initialize response", service, peer.clone()).await?;
let (response, response_id) = expect_response(
&mut transport,
"initialize response",
&service,
peer.clone(),
)
.await?;
if id != response_id { if id != response_id {
return Err(ClientInitializeError::ConflictInitResponseId( return Err(ClientInitializeError::ConflictInitResponseId(
@ -285,7 +420,115 @@ where
transport.send(notification).await.map_err(|error| { transport.send(notification).await.map_err(|error| {
ClientInitializeError::transport::<T>(error, "send initialized notification") 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 { macro_rules! method {

View file

@ -217,12 +217,41 @@ where
} }
}; };
let ClientRequest::InitializeRequest(peer_info) = &request else { let initialize_request = match request {
return Err(ServerInitializeError::ExpectedInitializeRequest(Some( ClientRequest::InitializeRequest(request) => request,
ClientJsonRpcMessage::request(request, id), 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 { let context = RequestContext {
ct: ct.child_token(), ct: ct.child_token(),
id: id.clone(), id: id.clone(),
@ -231,7 +260,7 @@ where
peer: peer.clone(), peer: peer.clone(),
}; };
// Send initialize response // 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 { 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) => {
@ -247,13 +276,10 @@ where
return Err(ServerInitializeError::InitializeFailed(e)); return Err(ServerInitializeError::InitializeFailed(e));
} }
}; };
init_response.protocol_version = negotiate_protocol_version( init_response.protocol_version =
&peer_info.params.protocol_version, negotiate_protocol_version(&requested_protocol_version, init_response.protocol_version);
init_response.protocol_version,
);
// 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.
let mut negotiated_peer_info = peer_info.params.clone();
negotiated_peer_info.protocol_version = init_response.protocol_version.clone(); negotiated_peer_info.protocol_version = init_response.protocol_version.clone();
peer.set_peer_info(negotiated_peer_info); peer.set_peer_info(negotiated_peer_info);
transport transport

View file

@ -19,8 +19,9 @@ use super::common::client_side_sse::{
use crate::{ use crate::{
RoleClient, RoleClient,
model::{ model::{
ClientJsonRpcMessage, ClientNotification, ErrorData, InitializedNotification, JsonObject, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetMeta,
ProtocolVersion, RequestId, ServerJsonRpcMessage, ServerResult, InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage,
ServerResult,
}, },
transport::{ transport::{
common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, common::{client_side_sse::SseAutoReconnectStream, mcp_headers},
@ -29,6 +30,7 @@ use crate::{
}; };
type BoxedSseStream = BoxStream<'static, Result<Sse, SseError>>; type BoxedSseStream = BoxStream<'static, Result<Sse, SseError>>;
const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
fn build_request_headers( fn build_request_headers(
base: &HashMap<HeaderName, HeaderValue>, base: &HashMap<HeaderName, HeaderValue>,
@ -58,6 +60,27 @@ fn build_request_headers(
headers headers
} }
fn request_version_headers(
base: &HashMap<HeaderName, HeaderValue>,
message: &ClientJsonRpcMessage,
fallback: &ProtocolVersion,
tool_cache: &HashMap<String, Arc<JsonObject>>,
) -> (ProtocolVersion, HashMap<HeaderName, HeaderValue>) {
let version = match message {
ClientJsonRpcMessage::Request(request) => request
.request
.get_meta()
.protocol_version()
.unwrap_or_else(|| fallback.clone()),
_ => fallback.clone(),
};
let mut headers = build_request_headers(base, message, tool_cache, &version);
if let Ok(value) = HeaderValue::from_str(version.as_str()) {
headers.insert(HeaderName::from_static("mcp-protocol-version"), value);
}
(version, headers)
}
fn cache_tools_from_response( fn cache_tools_from_response(
cache: &mut HashMap<String, Arc<JsonObject>>, cache: &mut HashMap<String, Arc<JsonObject>>,
message: &ServerJsonRpcMessage, message: &ServerJsonRpcMessage,
@ -184,7 +207,10 @@ pub enum StreamableHttpProtocolError {
MissingSessionIdInResponse, MissingSessionIdInResponse,
} }
#[allow(clippy::large_enum_variant)] #[expect(
clippy::large_enum_variant,
reason = "boxing the streaming response would add an allocation to the common response path"
)]
#[non_exhaustive] #[non_exhaustive]
pub enum StreamableHttpPostResponse { pub enum StreamableHttpPostResponse {
Accepted, Accepted,
@ -651,6 +677,67 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
Ok(()) Ok(())
} }
fn spawn_common_stream(
streams: &mut tokio::task::JoinSet<Result<(), StreamableHttpError<C::Error>>>,
client: C,
session_id: Arc<str>,
config: &StreamableHttpClientTransportConfig,
protocol_headers: HashMap<HeaderName, HeaderValue>,
sse_worker_tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
transport_task_ct: CancellationToken,
) {
let uri = config.uri.clone();
let auth_header = config.auth_header.clone();
let retry_config = config.retry_config.clone();
let reconnect_uri = config.uri.clone();
let reconnect_auth_header = config.auth_header.clone();
let max_sse_event_size = config.max_sse_event_size;
streams.spawn(async move {
match client
.get_stream_with_max_sse_event_size(
uri,
session_id.clone(),
None,
auth_header,
protocol_headers.clone(),
max_sse_event_size,
)
.await
{
Ok(stream) => {
let sse_stream = SseAutoReconnectStream::new(
stream,
StreamableHttpClientReconnect {
client,
session_id,
uri: reconnect_uri,
auth_header: reconnect_auth_header,
custom_headers: protocol_headers,
max_sse_event_size,
},
retry_config,
);
Self::execute_sse_stream(
sse_stream,
sse_worker_tx,
false,
transport_task_ct.child_token(),
)
.await
}
Err(StreamableHttpError::ServerDoesNotSupportSse) => {
tracing::debug!("server doesn't support sse, skip common stream");
Ok(())
}
Err(error) => {
tracing::error!("fail to get common stream: {error}");
Err(error)
}
}
});
}
/// Performs a transparent re-initialization handshake after a session-expired 404. /// Performs a transparent re-initialization handshake after a session-expired 404.
/// ///
/// Takes an owned clone of the client (avoiding `&self` across `.await` so the /// Takes an owned clone of the client (avoiding `&self` across `.await` so the
@ -667,8 +754,14 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
auth_header: Option<String>, auth_header: Option<String>,
custom_headers: HashMap<HeaderName, HeaderValue>, custom_headers: HashMap<HeaderName, HeaderValue>,
max_sse_event_size: usize, max_sse_event_size: usize,
) -> Result<(Option<Arc<str>>, HashMap<HeaderName, HeaderValue>), StreamableHttpError<C::Error>> ) -> Result<
{ (
Option<Arc<str>>,
ProtocolVersion,
HashMap<HeaderName, HeaderValue>,
),
StreamableHttpError<C::Error>,
> {
let (init_msg, new_session_id_str) = client let (init_msg, new_session_id_str) = client
.post_message_with_max_sse_event_size( .post_message_with_max_sse_event_size(
uri.clone(), uri.clone(),
@ -712,7 +805,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
.await? .await?
.expect_accepted_or_json::<C::Error>()?; .expect_accepted_or_json::<C::Error>()?;
Ok((new_session_id, new_protocol_headers)) Ok((new_session_id, negotiated_version, new_protocol_headers))
} }
} }
@ -743,17 +836,33 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
let _drop_guard = transport_task_ct.clone().drop_guard(); let _drop_guard = transport_task_ct.clone().drop_guard();
let WorkerSendRequest { let WorkerSendRequest {
responder, responder,
message: initialize_request, message: startup_request,
} = context.recv_from_handler().await?; } = context.recv_from_handler().await?;
let saved_init_request = initialize_request.clone(); let is_legacy_startup = matches!(
&startup_request,
ClientJsonRpcMessage::Request(request)
if matches!(&request.request, ClientRequest::InitializeRequest(_))
);
let mut saved_init_request = is_legacy_startup.then(|| startup_request.clone());
let empty_tool_cache = HashMap::new();
let (bootstrap_version, bootstrap_headers) = if is_legacy_startup {
(ProtocolVersion::default(), config.custom_headers.clone())
} else {
request_version_headers(
&config.custom_headers,
&startup_request,
&ProtocolVersion::default(),
&empty_tool_cache,
)
};
let (message, session_id) = match self let (message, session_id) = match self
.client .client
.post_message_with_max_sse_event_size( .post_message_with_max_sse_event_size(
config.uri.clone(), config.uri.clone(),
initialize_request, startup_request,
None, None,
config.auth_header.clone(), config.auth_header.clone(),
config.custom_headers.clone(), bootstrap_headers.clone(),
config.max_sse_event_size, config.max_sse_event_size,
) )
.await .await
@ -784,8 +893,11 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
} }
None None
}; };
let (negotiated_version, mut protocol_headers) = let (mut negotiated_version, mut protocol_headers) = if is_legacy_startup {
negotiate_version_headers(&message, config.custom_headers.clone()); negotiate_version_headers(&message, config.custom_headers.clone())
} else {
(bootstrap_version, bootstrap_headers)
};
// SEP-2243: tool input schemas (name -> schema) cached from tools/list responses, // SEP-2243: tool input schemas (name -> schema) cached from tools/list responses,
// used to promote annotated tools/call arguments to Mcp-Param-* headers. // used to promote annotated tools/call arguments to Mcp-Param-* headers.
let mut tool_header_cache: HashMap<String, Arc<JsonObject>> = HashMap::new(); let mut tool_header_cache: HashMap<String, Arc<JsonObject>> = HashMap::new();
@ -800,33 +912,37 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
}); });
context.send_to_handler(message).await?; context.send_to_handler(message).await?;
let initialized_notification = context.recv_from_handler().await?; if is_legacy_startup {
// expect a initialized response let initialized_notification = context.recv_from_handler().await?;
let initialized_headers = build_request_headers( let initialized_headers = build_request_headers(
&protocol_headers, &protocol_headers,
&initialized_notification.message, &initialized_notification.message,
&tool_header_cache, &tool_header_cache,
&negotiated_version, &negotiated_version,
); );
self.client self.client
.post_message_with_max_sse_event_size( .post_message_with_max_sse_event_size(
config.uri.clone(), config.uri.clone(),
initialized_notification.message, initialized_notification.message,
session_id.clone(), session_id.clone(),
config.auth_header.clone(), config.auth_header.clone(),
initialized_headers, initialized_headers,
config.max_sse_event_size, config.max_sse_event_size,
) )
.await .await
.map_err(WorkerQuitReason::fatal_context( .map_err(WorkerQuitReason::fatal_context(
"send initialized notification", "send initialized notification",
))? ))?
.expect_accepted_or_json::<C::Error>() .expect_accepted_or_json::<C::Error>()
.map_err(WorkerQuitReason::fatal_context( .map_err(WorkerQuitReason::fatal_context(
"process initialized notification response", "process initialized notification response",
))?; ))?;
let _ = initialized_notification.responder.send(Ok(())); let _ = initialized_notification.responder.send(Ok(()));
#[allow(clippy::large_enum_variant)] }
#[expect(
clippy::large_enum_variant,
reason = "the event is short-lived and boxing would add allocation in the event loop"
)]
enum Event<W: Worker, E: std::error::Error + Send + Sync + 'static> { enum Event<W: Worker, E: std::error::Error + Send + Sync + 'static> {
ClientMessage(WorkerSendRequest<W>), ClientMessage(WorkerSendRequest<W>),
ServerMessage(ServerJsonRpcMessage), ServerMessage(ServerJsonRpcMessage),
@ -834,63 +950,17 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
} }
let mut streams = tokio::task::JoinSet::new(); let mut streams = tokio::task::JoinSet::new();
let mut pending_stream_response_ids = HashSet::new(); let mut pending_stream_response_ids = HashSet::new();
let mut awaiting_fallback_initialized = false;
if let Some(session_id) = &session_id { if let Some(session_id) = &session_id {
let client = self.client.clone(); Self::spawn_common_stream(
let uri = config.uri.clone(); &mut streams,
let session_id = session_id.clone(); self.client.clone(),
let auth_header = config.auth_header.clone(); session_id.clone(),
let retry_config = self.config.retry_config.clone(); &config,
let sse_worker_tx = sse_worker_tx.clone(); protocol_headers.clone(),
let transport_task_ct = transport_task_ct.clone(); sse_worker_tx.clone(),
let config_uri = config.uri.clone(); transport_task_ct.clone(),
let config_auth_header = config.auth_header.clone(); );
let spawn_headers = protocol_headers.clone();
let max_sse_event_size = config.max_sse_event_size;
streams.spawn(async move {
match client
.get_stream_with_max_sse_event_size(
uri.clone(),
session_id.clone(),
None,
auth_header.clone(),
spawn_headers.clone(),
max_sse_event_size,
)
.await
{
Ok(stream) => {
let sse_stream = SseAutoReconnectStream::new(
stream,
StreamableHttpClientReconnect {
client: client.clone(),
session_id: session_id.clone(),
uri: config_uri,
auth_header: config_auth_header,
custom_headers: spawn_headers,
max_sse_event_size,
},
retry_config,
);
Self::execute_sse_stream(
sse_stream,
sse_worker_tx,
false,
transport_task_ct.child_token(),
)
.await
}
Err(StreamableHttpError::ServerDoesNotSupportSse) => {
tracing::debug!("server doesn't support sse, skip common stream");
Ok(())
}
Err(e) => {
// fail to get common stream
tracing::error!("fail to get common stream: {e}");
Err(e)
}
}
});
} }
// Main event loop - capture exit reason so we can do cleanup before returning // Main event loop - capture exit reason so we can do cleanup before returning
let loop_result: Result<(), WorkerQuitReason<Self::Error>> = 'main_loop: loop { let loop_result: Result<(), WorkerQuitReason<Self::Error>> = 'main_loop: loop {
@ -926,16 +996,112 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
match event { match event {
Event::ClientMessage(send_request) => { Event::ClientMessage(send_request) => {
let WorkerSendRequest { message, responder } = send_request; let WorkerSendRequest { message, responder } = send_request;
let is_fallback_initialize = saved_init_request.is_none()
&& matches!(
&message,
ClientJsonRpcMessage::Request(request)
if matches!(
&request.request,
ClientRequest::InitializeRequest(_)
)
);
if is_fallback_initialize {
saved_init_request = Some(message.clone());
// Servers do not assign sessions to `server/discover`, so a
// fallback initialize starts from a clean slate: no session
// ID, no cleanup state, and no streams to tear down.
debug_assert!(
session_id.is_none()
&& session_cleanup_info.is_none()
&& streams.is_empty(),
"discover bootstrap must not create session state"
);
let response = self
.client
.post_message_with_max_sse_event_size(
config.uri.clone(),
message,
None,
config.auth_header.clone(),
config.custom_headers.clone(),
config.max_sse_event_size,
)
.await;
let response = match response {
Ok(response) => {
let _ = responder.send(Ok(()));
response
}
Err(error) => {
let _ = responder.send(Err(error));
continue;
}
};
let (initialize_response, new_session_id) = response
.expect_initialized::<C::Error>()
.await
.map_err(WorkerQuitReason::fatal_context(
"process fallback initialize response",
))?;
session_id = new_session_id.map(Arc::from);
if session_id.is_none() && !config.allow_stateless {
return Err(WorkerQuitReason::fatal(
StreamableHttpError::<C::Error>::MissingSessionIdInResponse,
"process fallback initialize response",
));
}
(negotiated_version, protocol_headers) = negotiate_version_headers(
&initialize_response,
config.custom_headers.clone(),
);
session_cleanup_info =
session_id.as_ref().map(|session_id| SessionCleanupInfo {
client: self.client.clone(),
uri: config.uri.clone(),
session_id: session_id.clone(),
auth_header: config.auth_header.clone(),
protocol_headers: protocol_headers.clone(),
});
context.send_to_handler(initialize_response).await?;
awaiting_fallback_initialized = true;
continue;
}
let request_id = Self::client_request_id(&message); let request_id = Self::client_request_id(&message);
let inline_version = match &message {
ClientJsonRpcMessage::Request(request) => {
request.request.get_meta().protocol_version()
}
_ => None,
};
let is_initialized_notification = matches!(
&message,
ClientJsonRpcMessage::Notification(notification)
if matches!(
&notification.notification,
ClientNotification::InitializedNotification(_)
)
);
// Pass a clone to the first attempt so `message` is retained for a // Pass a clone to the first attempt so `message` is retained for a
// potential re-init retry. `post_message` takes ownership and the // potential re-init retry. `post_message` takes ownership and the
// trait cannot be changed, so the clone is unavoidable. // trait cannot be changed, so the clone is unavoidable.
let request_headers = build_request_headers( let (request_version, request_headers) = request_version_headers(
&protocol_headers, &protocol_headers,
&message, &message,
&tool_header_cache,
&negotiated_version, &negotiated_version,
&tool_header_cache,
); );
if inline_version.is_some() {
negotiated_version = request_version.clone();
if let Ok(value) = HeaderValue::from_str(request_version.as_str()) {
protocol_headers
.insert(HeaderName::from_static("mcp-protocol-version"), value);
}
if let Some(cleanup) = &mut session_cleanup_info {
cleanup.protocol_headers = protocol_headers.clone();
}
}
let response = self let response = self
.client .client
.post_message_with_max_sse_event_size( .post_message_with_max_sse_event_size(
@ -949,9 +1115,10 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
.await; .await;
let send_result = match response { let send_result = match response {
Err(StreamableHttpError::SessionExpired) => { Err(StreamableHttpError::SessionExpired) => {
if !config.reinit_on_expired_session { if let Some(saved_init_request) = saved_init_request
Err(StreamableHttpError::SessionExpired) .as_ref()
} else { .filter(|_| config.reinit_on_expired_session)
{
// The server discarded the session (HTTP 404). Perform a // The server discarded the session (HTTP 404). Perform a
// fresh handshake once and replay the original message. // fresh handshake once and replay the original message.
tracing::info!( tracing::info!(
@ -967,7 +1134,11 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
) )
.await .await
{ {
Ok((new_session_id, new_protocol_headers)) => { Ok((
new_session_id,
new_negotiated_version,
new_protocol_headers,
)) => {
// Old streams hold the stale session ID. Stop them first // Old streams hold the stale session ID. Stop them first
// so no late stale-session messages can arrive after the // so no late stale-session messages can arrive after the
// pending requests below are completed. // pending requests below are completed.
@ -990,6 +1161,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
.await?; .await?;
session_id = new_session_id; session_id = new_session_id;
negotiated_version = new_negotiated_version;
protocol_headers = new_protocol_headers; protocol_headers = new_protocol_headers;
session_cleanup_info = session_cleanup_info =
session_id.as_ref().map(|sid| SessionCleanupInfo { session_id.as_ref().map(|sid| SessionCleanupInfo {
@ -1001,71 +1173,22 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
}); });
if let Some(new_sid) = &session_id { if let Some(new_sid) = &session_id {
let client = self.client.clone(); Self::spawn_common_stream(
let uri = config.uri.clone(); &mut streams,
let new_sid = new_sid.clone(); self.client.clone(),
let auth_header = config.auth_header.clone(); new_sid.clone(),
let retry_config = self.config.retry_config.clone(); &config,
let sse_tx = sse_worker_tx.clone(); protocol_headers.clone(),
let task_ct = transport_task_ct.clone(); sse_worker_tx.clone(),
let config_uri = config.uri.clone(); transport_task_ct.clone(),
let config_auth = config.auth_header.clone(); );
let spawn_headers = protocol_headers.clone();
let max_sse_event_size = config.max_sse_event_size;
streams.spawn(async move {
match client
.get_stream_with_max_sse_event_size(
uri,
new_sid.clone(),
None,
auth_header.clone(),
spawn_headers.clone(),
max_sse_event_size,
)
.await
{
Ok(stream) => {
let sse_stream = SseAutoReconnectStream::new(
stream,
StreamableHttpClientReconnect {
client: client.clone(),
session_id: new_sid,
uri: config_uri,
auth_header: config_auth,
custom_headers: spawn_headers,
max_sse_event_size,
},
retry_config,
);
Self::execute_sse_stream(
sse_stream,
sse_tx,
false,
task_ct.child_token(),
)
.await
}
Err(StreamableHttpError::ServerDoesNotSupportSse) => {
tracing::debug!(
"server doesn't support sse after re-init"
);
Ok(())
}
Err(e) => {
tracing::error!(
"fail to get common stream after re-init: {e}"
);
Err(e)
}
}
});
} }
let retry_headers = build_request_headers( let (_, retry_headers) = request_version_headers(
&protocol_headers, &protocol_headers,
&message, &message,
&tool_header_cache,
&negotiated_version, &negotiated_version,
&tool_header_cache,
); );
let retry_response = self let retry_response = self
.client .client
@ -1126,7 +1249,9 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
} }
Err(reinit_err) => Err(reinit_err), Err(reinit_err) => Err(reinit_err),
} }
} // else enable_reinit_on_expired_session } else {
Err(StreamableHttpError::SessionExpired)
}
} }
Err(e) => Err(e), Err(e) => Err(e),
Ok(StreamableHttpPostResponse::Accepted) => { Ok(StreamableHttpPostResponse::Accepted) => {
@ -1167,6 +1292,23 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
Ok(()) Ok(())
} }
}; };
if send_result.is_ok()
&& awaiting_fallback_initialized
&& is_initialized_notification
{
if let Some(session_id) = &session_id {
Self::spawn_common_stream(
&mut streams,
self.client.clone(),
session_id.clone(),
&config,
protocol_headers.clone(),
sse_worker_tx.clone(),
transport_task_ct.clone(),
);
}
awaiting_fallback_initialized = false;
}
let _ = responder.send(send_result); let _ = responder.send(send_result);
} }
Event::ServerMessage(json_rpc_message) => { Event::ServerMessage(json_rpc_message) => {
@ -1194,7 +1336,6 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
// Cleanup session before returning (ensures close() waits for session deletion) // Cleanup session before returning (ensures close() waits for session deletion)
// Use a timeout to prevent indefinite hangs if the server is unresponsive // Use a timeout to prevent indefinite hangs if the server is unresponsive
if let Some(cleanup) = session_cleanup_info { if let Some(cleanup) = session_cleanup_info {
const SESSION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
let cleanup_session_id = cleanup.session_id.clone(); let cleanup_session_id = cleanup.session_id.clone();
match tokio::time::timeout( match tokio::time::timeout(
SESSION_CLEANUP_TIMEOUT, SESSION_CLEANUP_TIMEOUT,

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"))); 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] #[test]
fn meta_accessors_decode_wire_values() { fn meta_accessors_decode_wire_values() {
let meta: RequestMetaObject = serde_json::from_value(json!({ 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, ServiceError, ServiceExt,
model::{ model::{
CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode, CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode,
ErrorData, InitializeResult, PingRequest, RequestId, ServerCapabilities, ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities,
ServerJsonRpcMessage, ServerResult, ServerJsonRpcMessage, ServerResult,
}, },
transport::{ transport::{
@ -90,7 +90,7 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
message: ClientJsonRpcMessage, message: ClientJsonRpcMessage,
_session_id: Option<Arc<str>>, _session_id: Option<Arc<str>>,
_auth_header: Option<String>, _auth_header: Option<String>,
_custom_headers: HashMap<HeaderName, HeaderValue>, custom_headers: HashMap<HeaderName, HeaderValue>,
) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> { ) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
let mut state = self.state.lock().await; let mut state = self.state.lock().await;
match state match state
@ -100,15 +100,23 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
{ {
MockPost::Initialize => { MockPost::Initialize => {
state.session_counter += 1; 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 { let id = match message {
ClientJsonRpcMessage::Request(request) => request.id, ClientJsonRpcMessage::Request(request) => request.id,
other => panic!("expected initialize request, got {other:?}"), other => panic!("expected initialize request, got {other:?}"),
}; };
Ok(StreamableHttpPostResponse::Json( Ok(StreamableHttpPostResponse::Json(
ServerJsonRpcMessage::response( ServerJsonRpcMessage::response(
ServerResult::InitializeResult(InitializeResult::new( ServerResult::InitializeResult(
ServerCapabilities::builder().enable_tools().build(), InitializeResult::new(
)), ServerCapabilities::builder().enable_tools().build(),
)
.with_protocol_version(protocol_version),
),
id, id,
), ),
Some(format!("session-{}", state.session_counter)), Some(format!("session-{}", state.session_counter)),
@ -123,6 +131,12 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
} }
MockPost::Accepted => { MockPost::Accepted => {
if state.posts.is_empty() { 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); self.final_retry_accepted.add_permits(1);
} else { } else {
self.initial_request_accepted.add_permits(1); self.initial_request_accepted.add_permits(1);

View file

@ -74,6 +74,41 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
``` ```
</details> </details>
### 客户端生命周期模式
`serve()` 使用传统 MCP 生命周期:客户端发送 `initialize`,接收协商后的服务端信息,
然后发送 `notifications/initialized`。如需显式选择其他生命周期,请使用
[`ClientServiceExt::serve_with_lifecycle`](../../crates/rmcp/src/service/client.rs):
```rust, ignore
use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion};
// 直接通过 server/discover 启动,并在每个请求中携带客户端元数据。
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
// 或先尝试发现生命周期;当传统服务端报告未实现 server/discover 时回退。
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?;
```
`ClientLifecycleMode::Initialize` 等同于现有的 `serve()` 行为。发现启动不会发送
`notifications/initialized`;发现过程即完成启动,后续每个请求都会在 `_meta`
中携带协议版本、客户端信息和客户端能力。
### 构建服务端 ### 构建服务端
<details> <details>
@ -804,10 +839,11 @@ impl ServerHandler for MyServer {
### 初始化通知 ### 初始化通知
客户端在握手完成后发送 `initialized` 通知: 传统客户端在 `initialize` 握手完成后发送 `initialized` 通知。
使用 `ClientLifecycleMode::Discover` 的客户端不会发送此通知:
```rust ```rust
// 在 serve() 握手过程中由 rmcp 自动发送。 // 在传统 serve() 握手过程中由 rmcp 自动发送。
// 服务端通过以下方式处理: // 服务端通过以下方式处理:
impl ServerHandler for MyServer { impl ServerHandler for MyServer {
async fn on_initialized( async fn on_initialized(