Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ jobs:
--scenario sep-2322-client-request-state \
-o conformance-client-results/mrtr

- name: Run draft SEP-2575 client scenario

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this combine/relate to https://github.com/modelcontextprotocol/rust-sdk/pull/991/changes#diff-bd4df60e4b8977afa547a4b2976c2aed79b2113b0fffad61810242961e7fc930R925 ?
cc @jamadeo

Ideally we'd just set the conformance tests to run all the scenarios relevant for the new version and see the list of failures get smaller as we check in support for things.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexhancock That PR added an interim plumbing so the scenarios could run at all without SDK changes. This PR actually replaces that harness, allowing all routes to go through the real public API.

Regarding running the whole draft suite, I thought about it, but the runner exits with a non-zero status on any failure. So, running all draft scenarios today would keep CI red until the remaining SEPs are completed. I'm fine with that, but we would need to be a bit careful not to miss any regressions.

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
if: always()
uses: actions/upload-artifact@v7
Expand Down
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
```
</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

<details>
Expand Down Expand Up @@ -812,10 +850,11 @@ impl ServerHandler for MyServer {

### 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
// Sent automatically by rmcp during the serve() handshake.
// Sent automatically by rmcp during the legacy serve() handshake.
// Servers handle it via:
impl ServerHandler for MyServer {
async fn on_initialized(
Expand Down
103 changes: 18 additions & 85 deletions conformance/src/bin/client.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use rmcp::{
ClientHandler, ErrorData, RoleClient, ServiceExt,
ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt,
model::*,
service::{RequestContext, serve_directly},
service::RequestContext,
transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{AuthorizationCallback, OAuthState},
Expand Down Expand Up @@ -846,96 +846,29 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
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 {
std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION")
.ok()
.and_then(|version| serde_json::from_value(Value::String(version)).ok())
.unwrap_or(ProtocolVersion::V_2026_07_28)
}

impl rmcp::transport::Transport<RoleClient> for StatelessHttpTransport {
type Error = std::io::Error;

fn send(
&mut self,
item: rmcp::model::ClientJsonRpcMessage,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static {
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(())
/// Runs draft stateless scenarios through the public discover lifecycle and
/// Streamable HTTP transport.
async fn run_discover_client(server_url: &str) -> anyhow::Result<()> {
let mut preferred_versions = vec![conformance_protocol_version()];
for version in ProtocolVersion::KNOWN_VERSIONS.iter().rev() {
if !preferred_versions.contains(version) {
preferred_versions.push(version.clone());
}
}

async fn receive(&mut self) -> Option<ServerJsonRpcMessage> {
self.rx.recv().await
}

async fn close(&mut self) -> Result<(), Self::Error> {
Ok(())
}
}

/// 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 transport = StreamableHttpClientTransport::from_uri(server_url);
let client = FullClientHandler
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover { preferred_versions },
)
.await?;

let tools = client.list_tools(Default::default()).await?;
tracing::debug!("Listed {} tools", tools.tools.len());
Expand Down Expand Up @@ -989,14 +922,14 @@ async fn main() -> anyhow::Result<()> {
match scenario.as_str() {
// Non-auth scenarios
"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?,
"elicitation-sep1034-client-defaults" => {
run_elicitation_defaults_client(&server_url).await?
}
"sse-retry" => run_sse_retry_client(&server_url).await?,
"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" => {
run_tools_call_client(&server_url, &ctx).await?
Expand Down
20 changes: 20 additions & 0 deletions crates/rmcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,26 @@ name = "test_protocol_version_negotiation"
required-features = ["server", "client"]
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]]
name = "test_streamable_http_standard_headers"
required-features = ["server", "client", "transport-streamable-http-server", "reqwest"]
Expand Down
38 changes: 23 additions & 15 deletions crates/rmcp/src/handler/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,30 @@ impl<H: ServerHandler> Service<RoleServer> for H {
));
}
}
if matches!(&request, ClientRequest::DiscoverRequest(_)) {
if requested_version.is_none() {
// Self-contained metadata is required only when the request itself uses
// 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(
"server/discover requires protocolVersion in request _meta",
None,
));
}
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",
format!(
"request _meta is missing or has malformed required fields: {}",
missing.join(", ")
),
None,
));
}
Expand Down
7 changes: 5 additions & 2 deletions crates/rmcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ pub use handler::client::ClientHandler;
pub use handler::server::ServerHandler;
#[cfg(feature = "server")]
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"))]
pub use service::{Peer, Service, ServiceError, ServiceExt};
#[cfg(feature = "client")]
pub use service::{RoleClient, select_protocol_version, serve_client};
#[cfg(feature = "server")]
pub use service::{RoleServer, serve_server};

Expand Down
13 changes: 13 additions & 0 deletions crates/rmcp/src/model/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,19 @@ impl RequestMetaObject {
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 {
static EMPTY: std::sync::OnceLock<RequestMetaObject> = std::sync::OnceLock::new();
EMPTY.get_or_init(Default::default)
Expand Down
Loading