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:
parent
81745f1375
commit
2e2c79173c
17 changed files with 1525 additions and 306 deletions
8
.github/workflows/conformance.yml
vendored
8
.github/workflows/conformance.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
¬ification.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,
|
||||||
|
|
|
||||||
312
crates/rmcp/tests/test_client_lifecycle_modes.rs
Normal file
312
crates/rmcp/tests/test_client_lifecycle_modes.rs
Normal 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");
|
||||||
|
}
|
||||||
137
crates/rmcp/tests/test_discover_http_client_startup.rs
Normal file
137
crates/rmcp/tests/test_discover_http_client_startup.rs
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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!({
|
||||||
|
|
|
||||||
196
crates/rmcp/tests/test_stateless_server_requests.rs
Normal file
196
crates/rmcp/tests/test_stateless_server_requests.rs
Normal 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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue