Skip to content

Commit 2e2c791

Browse files
authored
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
1 parent 81745f1 commit 2e2c791

17 files changed

Lines changed: 1525 additions & 306 deletions

.github/workflows/conformance.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ jobs:
226226
--scenario sep-2322-client-request-state \
227227
-o conformance-client-results/mrtr
228228
229+
- name: Run draft SEP-2575 client scenario
230+
run: |
231+
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \
232+
--command "$(pwd)/target/debug/conformance-client" \
233+
--scenario request-metadata \
234+
--spec-version draft \
235+
-o conformance-client-results/sep-2575
236+
229237
- name: Upload results
230238
if: always()
231239
uses: actions/upload-artifact@v7

README.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7474
```
7575
</details>
7676

77+
### Client lifecycle modes
78+
79+
`serve()` uses the legacy MCP lifecycle: the client sends `initialize`, receives
80+
the negotiated server information, and then sends `notifications/initialized`.
81+
Use [`ClientServiceExt::serve_with_lifecycle`](crates/rmcp/src/service/client.rs) to
82+
select another lifecycle explicitly:
83+
84+
```rust, ignore
85+
use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion};
86+
87+
// Start directly with server/discover and include client metadata on every request.
88+
let client = ClientInfo::default()
89+
.serve_with_lifecycle(
90+
transport,
91+
ClientLifecycleMode::Discover {
92+
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
93+
},
94+
)
95+
.await?;
96+
97+
// Or probe the discover lifecycle and fall back when a legacy server reports
98+
// that server/discover is not implemented.
99+
let client = ClientInfo::default()
100+
.serve_with_lifecycle(
101+
transport,
102+
ClientLifecycleMode::Auto {
103+
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
104+
legacy_version: Some(ProtocolVersion::V_2025_11_25),
105+
},
106+
)
107+
.await?;
108+
```
109+
110+
`ClientLifecycleMode::Initialize` is equivalent to the existing `serve()` behavior.
111+
Discover startup does not send `notifications/initialized`; discovery completes
112+
startup, and each subsequent request carries its protocol version, client
113+
information, and capabilities in `_meta`.
114+
77115
### Build a Server
78116

79117
<details>
@@ -812,10 +850,11 @@ impl ServerHandler for MyServer {
812850

813851
### Initialized notification
814852

815-
Clients send `initialized` after the handshake completes:
853+
Legacy clients send `initialized` after the `initialize` handshake completes.
854+
Clients using `ClientLifecycleMode::Discover` do not send this notification:
816855

817856
```rust
818-
// Sent automatically by rmcp during the serve() handshake.
857+
// Sent automatically by rmcp during the legacy serve() handshake.
819858
// Servers handle it via:
820859
impl ServerHandler for MyServer {
821860
async fn on_initialized(

conformance/src/bin/client.rs

Lines changed: 18 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use rmcp::{
2-
ClientHandler, ErrorData, RoleClient, ServiceExt,
2+
ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt,
33
model::*,
4-
service::{RequestContext, serve_directly},
4+
service::RequestContext,
55
transport::{
66
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
77
auth::{AuthorizationCallback, OAuthState},
@@ -846,96 +846,29 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
846846
Ok(())
847847
}
848848

849-
/// A minimal stateless client transport: every outgoing message is one HTTP
850-
/// POST and the JSON response body (if any) is queued for `receive()`.
851-
///
852-
/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle
853-
/// (no `initialize` handshake, plain JSON responses), which the session-based
854-
/// `StreamableHttpClientTransport` cannot do. The transport is harness
855-
/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs
856-
/// unchanged on top of it.
857-
struct StatelessHttpTransport {
858-
http: reqwest::Client,
859-
uri: std::sync::Arc<str>,
860-
tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
861-
rx: tokio::sync::mpsc::Receiver<ServerJsonRpcMessage>,
862-
}
863-
864-
impl StatelessHttpTransport {
865-
fn new(uri: &str) -> Self {
866-
let (tx, rx) = tokio::sync::mpsc::channel(16);
867-
Self {
868-
http: reqwest::Client::new(),
869-
uri: uri.into(),
870-
tx,
871-
rx,
872-
}
873-
}
874-
}
875-
876849
fn conformance_protocol_version() -> ProtocolVersion {
877850
std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION")
878851
.ok()
879852
.and_then(|version| serde_json::from_value(Value::String(version)).ok())
880853
.unwrap_or(ProtocolVersion::V_2026_07_28)
881854
}
882855

883-
impl rmcp::transport::Transport<RoleClient> for StatelessHttpTransport {
884-
type Error = std::io::Error;
885-
886-
fn send(
887-
&mut self,
888-
item: rmcp::model::ClientJsonRpcMessage,
889-
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static {
890-
let http = self.http.clone();
891-
let uri = self.uri.clone();
892-
let tx = self.tx.clone();
893-
async move {
894-
let response = http
895-
.post(uri.as_ref())
896-
.header(
897-
"MCP-Protocol-Version",
898-
conformance_protocol_version().as_str(),
899-
)
900-
.json(&item)
901-
.send()
902-
.await
903-
.map_err(std::io::Error::other)?;
904-
match response.json::<ServerJsonRpcMessage>().await {
905-
Ok(message) => {
906-
let _ = tx.send(message).await;
907-
}
908-
Err(_) => {
909-
// No JSON-RPC body (e.g. 202/204 for notifications).
910-
}
911-
}
912-
Ok(())
856+
/// Runs draft stateless scenarios through the public discover lifecycle and
857+
/// Streamable HTTP transport.
858+
async fn run_discover_client(server_url: &str) -> anyhow::Result<()> {
859+
let mut preferred_versions = vec![conformance_protocol_version()];
860+
for version in ProtocolVersion::KNOWN_VERSIONS.iter().rev() {
861+
if !preferred_versions.contains(version) {
862+
preferred_versions.push(version.clone());
913863
}
914864
}
915-
916-
async fn receive(&mut self) -> Option<ServerJsonRpcMessage> {
917-
self.rx.recv().await
918-
}
919-
920-
async fn close(&mut self) -> Result<(), Self::Error> {
921-
Ok(())
922-
}
923-
}
924-
925-
/// Runs a client using the draft stateless lifecycle.
926-
///
927-
/// Stateless servers do not implement the `initialize` handshake, so this
928-
/// uses `serve_directly`. The protocol version comes from
929-
/// `MCP_CONFORMANCE_PROTOCOL_VERSION` (defaulting to `2026-07-28`) and is
930-
/// used for both peer configuration and outgoing HTTP request headers.
931-
///
932-
/// Lists available tools and calls each one, allowing the SDK's high-level
933-
/// tool-call handling to process any request retries.
934-
async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> {
935-
let transport = StatelessHttpTransport::new(server_url);
936-
let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
937-
.with_protocol_version(conformance_protocol_version());
938-
let client = serve_directly(FullClientHandler, transport, Some(peer_info));
865+
let transport = StreamableHttpClientTransport::from_uri(server_url);
866+
let client = FullClientHandler
867+
.serve_with_lifecycle(
868+
transport,
869+
ClientLifecycleMode::Discover { preferred_versions },
870+
)
871+
.await?;
939872

940873
let tools = client.list_tools(Default::default()).await?;
941874
tracing::debug!("Listed {} tools", tools.tools.len());
@@ -989,14 +922,14 @@ async fn main() -> anyhow::Result<()> {
989922
match scenario.as_str() {
990923
// Non-auth scenarios
991924
"initialize" => run_basic_client(&server_url).await?,
992-
"json-schema-ref-no-deref" => run_stateless_client(&server_url).await?,
925+
"json-schema-ref-no-deref" => run_discover_client(&server_url).await?,
993926
"tools_call" => run_tools_call_client(&server_url, &ctx).await?,
994927
"elicitation-sep1034-client-defaults" => {
995928
run_elicitation_defaults_client(&server_url).await?
996929
}
997930
"sse-retry" => run_sse_retry_client(&server_url).await?,
998931
"request-metadata" | "sep-2322-client-request-state" => {
999-
run_stateless_client(&server_url).await?
932+
run_discover_client(&server_url).await?
1000933
}
1001934
"http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => {
1002935
run_tools_call_client(&server_url, &ctx).await?

crates/rmcp/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,26 @@ name = "test_protocol_version_negotiation"
302302
required-features = ["server", "client"]
303303
path = "tests/test_protocol_version_negotiation.rs"
304304

305+
[[test]]
306+
name = "test_client_lifecycle_modes"
307+
required-features = ["client", "server"]
308+
path = "tests/test_client_lifecycle_modes.rs"
309+
310+
[[test]]
311+
name = "test_stateless_server_requests"
312+
required-features = ["client", "server"]
313+
path = "tests/test_stateless_server_requests.rs"
314+
315+
[[test]]
316+
name = "test_discover_http_client_startup"
317+
required-features = [
318+
"client",
319+
"reqwest",
320+
"transport-streamable-http-client-reqwest",
321+
"transport-streamable-http-server",
322+
]
323+
path = "tests/test_discover_http_client_startup.rs"
324+
305325
[[test]]
306326
name = "test_streamable_http_standard_headers"
307327
required-features = ["server", "client", "transport-streamable-http-server", "reqwest"]

crates/rmcp/src/handler/server.rs

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,22 +41,30 @@ impl<H: ServerHandler> Service<RoleServer> for H {
4141
));
4242
}
4343
}
44-
if matches!(&request, ClientRequest::DiscoverRequest(_)) {
45-
if requested_version.is_none() {
44+
// Self-contained metadata is required only when the request itself uses
45+
// the inline lifecycle: a discover opener, a session that started without
46+
// `initialize`, or a request that declares 2026-07-28+ in its own _meta.
47+
// Sessions that negotiated via `initialize` (or `serve_directly`) keep the
48+
// session model and may omit per-request metadata.
49+
let requires_request_metadata = uses_inline_negotiation
50+
&& (matches!(&request, ClientRequest::DiscoverRequest(_))
51+
|| context.peer.request_metadata_required()
52+
|| requested_version.as_ref().is_some_and(|version| {
53+
version.as_str() >= ProtocolVersion::V_2026_07_28.as_str()
54+
}));
55+
if requires_request_metadata {
56+
// Inline lifecycle requests are defined by the 2026-07-28 protocol.
57+
// Validate that lifecycle contract even when a request selects an
58+
// older application protocol version.
59+
let missing = context
60+
.meta
61+
.missing_required_keys(&ProtocolVersion::V_2026_07_28);
62+
if !missing.is_empty() {
4663
return Err(McpError::invalid_params(
47-
"server/discover requires protocolVersion in request _meta",
48-
None,
49-
));
50-
}
51-
if context.meta.client_info().is_none() {
52-
return Err(McpError::invalid_params(
53-
"server/discover requires clientInfo in request _meta",
54-
None,
55-
));
56-
}
57-
if context.meta.client_capabilities().is_none() {
58-
return Err(McpError::invalid_params(
59-
"server/discover requires clientCapabilities in request _meta",
64+
format!(
65+
"request _meta is missing or has malformed required fields: {}",
66+
missing.join(", ")
67+
),
6068
None,
6169
));
6270
}

crates/rmcp/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ pub use handler::client::ClientHandler;
1616
pub use handler::server::ServerHandler;
1717
#[cfg(feature = "server")]
1818
pub use handler::server::wrapper::Json;
19+
#[cfg(feature = "client")]
20+
pub use service::{
21+
ClientLifecycleMode, ClientServiceExt, RoleClient, select_protocol_version, serve_client,
22+
serve_client_with_lifecycle,
23+
};
1924
#[cfg(any(feature = "client", feature = "server"))]
2025
pub use service::{Peer, Service, ServiceError, ServiceExt};
21-
#[cfg(feature = "client")]
22-
pub use service::{RoleClient, select_protocol_version, serve_client};
2326
#[cfg(feature = "server")]
2427
pub use service::{RoleServer, serve_server};
2528

crates/rmcp/src/model/meta.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,19 @@ impl RequestMetaObject {
439439
meta
440440
}
441441

442+
/// Create request metadata with the client context SEP-2575 requires on every request.
443+
pub fn with_client_context(
444+
protocol_version: ProtocolVersion,
445+
client_info: Implementation,
446+
client_capabilities: ClientCapabilities,
447+
) -> Self {
448+
let mut meta = Self::new();
449+
meta.set_protocol_version(protocol_version);
450+
meta.set_client_info(client_info);
451+
meta.set_client_capabilities(client_capabilities);
452+
meta
453+
}
454+
442455
pub(crate) fn static_empty() -> &'static Self {
443456
static EMPTY: std::sync::OnceLock<RequestMetaObject> = std::sync::OnceLock::new();
444457
EMPTY.get_or_init(Default::default)

0 commit comments

Comments
 (0)