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
5 changed files with 1525 additions and 306 deletions
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")));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn meta_accessors_decode_wire_values() {
|
||||
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,
|
||||
model::{
|
||||
CallToolRequestParams, ClientInfo, ClientJsonRpcMessage, ClientRequest, ErrorCode,
|
||||
ErrorData, InitializeResult, PingRequest, RequestId, ServerCapabilities,
|
||||
ErrorData, InitializeResult, PingRequest, ProtocolVersion, RequestId, ServerCapabilities,
|
||||
ServerJsonRpcMessage, ServerResult,
|
||||
},
|
||||
transport::{
|
||||
|
|
@ -90,7 +90,7 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
|
|||
message: ClientJsonRpcMessage,
|
||||
_session_id: Option<Arc<str>>,
|
||||
_auth_header: Option<String>,
|
||||
_custom_headers: HashMap<HeaderName, HeaderValue>,
|
||||
custom_headers: HashMap<HeaderName, HeaderValue>,
|
||||
) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
|
||||
let mut state = self.state.lock().await;
|
||||
match state
|
||||
|
|
@ -100,15 +100,23 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
|
|||
{
|
||||
MockPost::Initialize => {
|
||||
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 {
|
||||
ClientJsonRpcMessage::Request(request) => request.id,
|
||||
other => panic!("expected initialize request, got {other:?}"),
|
||||
};
|
||||
Ok(StreamableHttpPostResponse::Json(
|
||||
ServerJsonRpcMessage::response(
|
||||
ServerResult::InitializeResult(InitializeResult::new(
|
||||
ServerCapabilities::builder().enable_tools().build(),
|
||||
)),
|
||||
ServerResult::InitializeResult(
|
||||
InitializeResult::new(
|
||||
ServerCapabilities::builder().enable_tools().build(),
|
||||
)
|
||||
.with_protocol_version(protocol_version),
|
||||
),
|
||||
id,
|
||||
),
|
||||
Some(format!("session-{}", state.session_counter)),
|
||||
|
|
@ -123,6 +131,12 @@ impl StreamableHttpClient for ReinitDropsAcceptedResponseClient {
|
|||
}
|
||||
MockPost::Accepted => {
|
||||
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);
|
||||
} else {
|
||||
self.initial_request_accepted.add_permits(1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue