feat: add subscription listen streams (SEP-2575) (#1000)

* feat: add subscription listen streams

* refactor: reuse legacy request helper
This commit is contained in:
Dale Seo 2026-07-21 09:53:04 -04:00 • committed by GitHub
commit ff1a715b6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 3617 additions and 223 deletions

View file

@ -1160,21 +1160,24 @@
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request11"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request12"
},
{
"$ref": "#/definitions/Request13"
},
{
"$ref": "#/definitions/CustomRequest"
}
@ -1567,6 +1570,22 @@
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1582,7 +1601,7 @@
"params"
]
},
"Request11": {
"Request12": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1598,7 +1617,7 @@
"params"
]
},
"Request12": {
"Request13": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1699,10 +1718,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscribeRequestMethod"
"$ref": "#/definitions/SubscriptionsListenRequestMethod"
},
"params": {
"$ref": "#/definitions/SubscribeRequestParams"
"$ref": "#/definitions/SubscriptionsListenRequestParams"
}
},
"required": [
@ -1715,10 +1734,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/UnsubscribeRequestMethod"
"$ref": "#/definitions/SubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/UnsubscribeRequestParams"
"$ref": "#/definitions/SubscribeRequestParams"
}
},
"required": [
@ -1731,10 +1750,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
"$ref": "#/definitions/UnsubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
"$ref": "#/definitions/UnsubscribeRequestParams"
}
},
"required": [
@ -2303,6 +2322,77 @@
"uri"
]
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsListenRequestMethod": {
"type": "string",
"format": "const",
"const": "subscriptions/listen"
},
"SubscriptionsListenRequestParams": {
"description": "Parameters for opening a long-lived notification subscription.",
"type": "object",
"properties": {
"_meta": {
"description": "Protocol-level metadata. Required by the draft wire schema.",
"type": "object",
"properties": {
"io.modelcontextprotocol/clientCapabilities": {
"$ref": "#/definitions/ClientCapabilities"
},
"io.modelcontextprotocol/clientInfo": {
"$ref": "#/definitions/Implementation"
},
"io.modelcontextprotocol/logLevel": {
"$ref": "#/definitions/LoggingLevel"
},
"io.modelcontextprotocol/protocolVersion": {
"type": "string"
},
"progressToken": {
"$ref": "#/definitions/ProgressToken"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/clientCapabilities"
]
},
"notifications": {
"description": "Notification categories requested for this stream.",
"allOf": [
{
"$ref": "#/definitions/SubscriptionFilter"
}
]
}
},
"required": [
"_meta",
"notifications"
]
},
"TaskMetadata": {
"description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).",
"type": "object",

View file

@ -1160,21 +1160,24 @@
{
"$ref": "#/definitions/Request9"
},
{
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request10"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
"$ref": "#/definitions/RequestOptionalParam4"
},
{
"$ref": "#/definitions/Request11"
},
{
"$ref": "#/definitions/RequestOptionalParam5"
},
{
"$ref": "#/definitions/Request12"
},
{
"$ref": "#/definitions/Request13"
},
{
"$ref": "#/definitions/CustomRequest"
}
@ -1567,6 +1570,22 @@
]
},
"Request10": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
}
},
"required": [
"method",
"params"
]
},
"Request11": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1582,7 +1601,7 @@
"params"
]
},
"Request11": {
"Request12": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1598,7 +1617,7 @@
"params"
]
},
"Request12": {
"Request13": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
@ -1699,10 +1718,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscribeRequestMethod"
"$ref": "#/definitions/SubscriptionsListenRequestMethod"
},
"params": {
"$ref": "#/definitions/SubscribeRequestParams"
"$ref": "#/definitions/SubscriptionsListenRequestParams"
}
},
"required": [
@ -1715,10 +1734,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/UnsubscribeRequestMethod"
"$ref": "#/definitions/SubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/UnsubscribeRequestParams"
"$ref": "#/definitions/SubscribeRequestParams"
}
},
"required": [
@ -1731,10 +1750,10 @@
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/CallToolRequestMethod"
"$ref": "#/definitions/UnsubscribeRequestMethod"
},
"params": {
"$ref": "#/definitions/CallToolRequestParams"
"$ref": "#/definitions/UnsubscribeRequestParams"
}
},
"required": [
@ -2303,6 +2322,77 @@
"uri"
]
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsListenRequestMethod": {
"type": "string",
"format": "const",
"const": "subscriptions/listen"
},
"SubscriptionsListenRequestParams": {
"description": "Parameters for opening a long-lived notification subscription.",
"type": "object",
"properties": {
"_meta": {
"description": "Protocol-level metadata. Required by the draft wire schema.",
"type": "object",
"properties": {
"io.modelcontextprotocol/clientCapabilities": {
"$ref": "#/definitions/ClientCapabilities"
},
"io.modelcontextprotocol/clientInfo": {
"$ref": "#/definitions/Implementation"
},
"io.modelcontextprotocol/logLevel": {
"$ref": "#/definitions/LoggingLevel"
},
"io.modelcontextprotocol/protocolVersion": {
"type": "string"
},
"progressToken": {
"$ref": "#/definitions/ProgressToken"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/clientCapabilities"
]
},
"notifications": {
"description": "Notification categories requested for this stream.",
"allOf": [
{
"$ref": "#/definitions/SubscriptionFilter"
}
]
}
},
"required": [
"_meta",
"notifications"
]
},
"TaskMetadata": {
"description": "Metadata for augmenting a request with task execution (spec `TaskMetadata`).",
"type": "object",

View file

@ -1650,6 +1650,9 @@
{
"$ref": "#/definitions/Notification5"
},
{
"$ref": "#/definitions/Notification6"
},
{
"$ref": "#/definitions/CustomNotification"
}
@ -2222,6 +2225,21 @@
]
},
"Notification5": {
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod"
},
"params": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams"
}
},
"required": [
"method",
"params"
]
},
"Notification6": {
"type": "object",
"properties": {
"method": {
@ -3295,6 +3313,9 @@
{
"$ref": "#/definitions/ReadResourceResult"
},
{
"$ref": "#/definitions/SubscriptionsListenResult"
},
{
"$ref": "#/definitions/ListToolsResult"
},
@ -3438,6 +3459,75 @@
"format": "const",
"const": "string"
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsAcknowledgedNotificationMethod": {
"type": "string",
"format": "const",
"const": "notifications/subscriptions/acknowledged"
},
"SubscriptionsAcknowledgedNotificationParams": {
"description": "Parameters reporting the accepted subset of a subscription filter.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/NotificationMetaObject"
},
"notifications": {
"$ref": "#/definitions/SubscriptionFilter"
}
},
"required": [
"notifications"
]
},
"SubscriptionsListenResult": {
"description": "Final response indicating that a subscription ended gracefully.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/SubscriptionsListenResultMeta"
},
"resultType": {
"$ref": "#/definitions/ResultType"
}
},
"required": [
"resultType",
"_meta"
]
},
"SubscriptionsListenResultMeta": {
"type": "object",
"properties": {
"io.modelcontextprotocol/subscriptionId": {
"$ref": "#/definitions/NumberOrString"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/subscriptionId"
]
},
"Task": {
"description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).",
"type": "object",

View file

@ -1650,6 +1650,9 @@
{
"$ref": "#/definitions/Notification5"
},
{
"$ref": "#/definitions/Notification6"
},
{
"$ref": "#/definitions/CustomNotification"
}
@ -2222,6 +2225,21 @@
]
},
"Notification5": {
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationMethod"
},
"params": {
"$ref": "#/definitions/SubscriptionsAcknowledgedNotificationParams"
}
},
"required": [
"method",
"params"
]
},
"Notification6": {
"type": "object",
"properties": {
"method": {
@ -3295,6 +3313,9 @@
{
"$ref": "#/definitions/ReadResourceResult"
},
{
"$ref": "#/definitions/SubscriptionsListenResult"
},
{
"$ref": "#/definitions/ListToolsResult"
},
@ -3438,6 +3459,75 @@
"format": "const",
"const": "string"
},
"SubscriptionFilter": {
"description": "Notification categories a client opts in to on a `subscriptions/listen` stream.",
"type": "object",
"properties": {
"promptsListChanged": {
"type": "boolean"
},
"resourceSubscriptions": {
"type": "array",
"items": {
"type": "string"
}
},
"resourcesListChanged": {
"type": "boolean"
},
"toolsListChanged": {
"type": "boolean"
}
}
},
"SubscriptionsAcknowledgedNotificationMethod": {
"type": "string",
"format": "const",
"const": "notifications/subscriptions/acknowledged"
},
"SubscriptionsAcknowledgedNotificationParams": {
"description": "Parameters reporting the accepted subset of a subscription filter.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/NotificationMetaObject"
},
"notifications": {
"$ref": "#/definitions/SubscriptionFilter"
}
},
"required": [
"notifications"
]
},
"SubscriptionsListenResult": {
"description": "Final response indicating that a subscription ended gracefully.",
"type": "object",
"properties": {
"_meta": {
"$ref": "#/definitions/SubscriptionsListenResultMeta"
},
"resultType": {
"$ref": "#/definitions/ResultType"
}
},
"required": [
"resultType",
"_meta"
]
},
"SubscriptionsListenResultMeta": {
"type": "object",
"properties": {
"io.modelcontextprotocol/subscriptionId": {
"$ref": "#/definitions/NumberOrString"
}
},
"additionalProperties": true,
"required": [
"io.modelcontextprotocol/subscriptionId"
]
},
"Task": {
"description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).",
"type": "object",

View file

@ -281,9 +281,9 @@ fn client_info(protocol_version: ProtocolVersion) -> ClientInfo {
.with_protocol_version(protocol_version)
}
fn server_info_2026() -> ServerInfo {
fn server_info(protocol_version: ProtocolVersion) -> ServerInfo {
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
info.protocol_version = ProtocolVersion::V_2026_07_28;
info.protocol_version = protocol_version;
info
}
@ -302,6 +302,7 @@ where
.run_until(async move {
let (server_transport, client_transport) = tokio::io::duplex(8192);
let server_peer_info = client_info(client_protocol);
let client_peer_info = server_info(server_peer_info.protocol_version.clone());
let server_task = tokio::task::spawn_local(async move {
let running = serve_directly::<RoleServer, _, _, _, _>(
server,
@ -315,7 +316,7 @@ where
let client = serve_directly::<RoleClient, _, _, _, _>(
MrtrClient,
client_transport,
Some(server_info_2026()),
Some(client_peer_info),
);
let result = body(client).await;
@ -579,7 +580,7 @@ async fn request_state_codec_seals_and_verifies_through_the_loop() -> anyhow::Re
let client = serve_directly::<RoleClient, _, _, _, _>(
MrtrClient,
client_transport,
Some(server_info_2026()),
Some(server_info(ProtocolVersion::V_2026_07_28)),
);
let result = client

View file

@ -1,4 +1,5 @@
#![cfg(not(feature = "local"))]
#![allow(deprecated)]
use std::sync::Arc;
use rmcp::{

View file

@ -0,0 +1,719 @@
#![cfg(all(
not(feature = "local"),
feature = "client",
feature = "server",
feature = "transport-io"
))]
use std::{
num::NonZeroUsize,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use rmcp::{
ClientHandler, ClientServiceExt, ServerHandler, ServiceExt,
model::{
ClientNotification, ClientRequest, DiscoverResult, GetMeta, Implementation,
NotificationMetaObject, PromptListChangedNotification, ProtocolVersion, ServerCapabilities,
ServerInfo, ServerNotification, ServerResult, SubscriptionFilter,
SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams,
SubscriptionsListenResult,
},
service::{
NotificationContext, RequestContext, RoleClient, RoleServer, SubscriptionContext,
SubscriptionEnd, SubscriptionSendError, SubscriptionSink,
},
};
use tokio::sync::{Mutex, Notify};
struct ToolsOnlyServer;
#[derive(Clone)]
struct CountingClient {
tool_changes: Arc<AtomicUsize>,
}
impl ClientHandler for CountingClient {
async fn on_tool_list_changed(&self, _context: NotificationContext<RoleClient>) {
self.tool_changes.fetch_add(1, Ordering::Relaxed);
}
}
impl ServerHandler for ToolsOnlyServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
context
.sink()
.notify_tool_list_changed()
.await
.expect("accepted tool notification");
assert!(matches!(
context.sink().notify_prompt_list_changed().await,
Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/prompts/list_changed"
))
));
Ok(())
}
}
struct ToolsAndPromptsServer;
impl ServerHandler for ToolsAndPromptsServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.enable_prompts()
.enable_prompts_list_changed()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
if context.accepted().tools_list_changed == Some(true) {
context
.sink()
.notify_tool_list_changed()
.await
.expect("send tool notification");
}
if context.accepted().prompts_list_changed == Some(true) {
context
.sink()
.notify_prompt_list_changed()
.await
.expect("send prompt notification");
}
Ok(())
}
}
struct ResourceSubscriptionServer;
impl ServerHandler for ResourceSubscriptionServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_resources()
.enable_resources_subscribe()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
context
.sink()
.notify_resource_updated("file:///accepted")
.await
.expect("accepted URI");
assert!(matches!(
context
.sink()
.notify_resource_updated("file:///not-requested")
.await,
Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/updated"
))
));
Ok(())
}
}
struct RemoteCancellationServer;
impl ServerHandler for RemoteCancellationServer {
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.clone())
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
context
.request_context()
.peer
.notify_cancelled(rmcp::model::CancelledNotificationParam::new(
Some(context.sink().id().clone()),
Some("server shutdown".to_owned()),
))
.await
.expect("send server cancellation");
std::future::pending().await
}
}
struct FloodServer;
impl ServerHandler for FloodServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.clone())
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
for _ in 0..10 {
if context.sink().notify_tool_list_changed().await.is_err() {
break;
}
}
context.cancelled().await;
Ok(())
}
}
#[derive(Clone)]
struct ClosedSinkServer {
sink: Arc<Mutex<Option<SubscriptionSink>>>,
}
struct LeakyServer;
impl ServerHandler for LeakyServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
let mut notification =
ServerNotification::PromptListChangedNotification(PromptListChangedNotification {
method: Default::default(),
extensions: Default::default(),
});
notification
.get_meta_mut()
.set_subscription_id(context.sink().id().clone());
context
.request_context()
.peer
.send_notification(notification)
.await
.expect("send deliberately invalid notification");
std::future::pending().await
}
}
struct MalformedAcknowledgmentServer {
cancelled: Arc<Notify>,
}
impl rmcp::service::Service<RoleServer> for MalformedAcknowledgmentServer {
async fn handle_request(
&self,
request: ClientRequest,
context: RequestContext<RoleServer>,
) -> Result<ServerResult, rmcp::ErrorData> {
match request {
ClientRequest::DiscoverRequest(_) => {
Ok(ServerResult::DiscoverResult(DiscoverResult::new(
vec![ProtocolVersion::V_2026_07_28],
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.enable_prompts()
.enable_prompts_list_changed()
.build(),
Implementation::new("malformed-ack-server", "1.0.0"),
)))
}
ClientRequest::SubscriptionsListenRequest(_) => {
let mut acknowledgment = SubscriptionsAcknowledgedNotification::new(
SubscriptionsAcknowledgedNotificationParams::new(
SubscriptionFilter::builder().prompts_list_changed().build(),
),
);
let mut meta = NotificationMetaObject::new();
meta.set_subscription_id(context.id.clone());
acknowledgment.extensions.insert(meta);
context
.peer
.send_notification(ServerNotification::SubscriptionsAcknowledgedNotification(
acknowledgment,
))
.await
.map_err(|error| rmcp::ErrorData::internal_error(error.to_string(), None))?;
context.ct.cancelled().await;
Ok(ServerResult::SubscriptionsListenResult(
SubscriptionsListenResult::complete(context.id),
))
}
_ => Err(rmcp::ErrorData::invalid_request(
"unexpected test request",
None,
)),
}
}
async fn handle_notification(
&self,
notification: ClientNotification,
_context: NotificationContext<RoleServer>,
) -> Result<(), rmcp::ErrorData> {
if matches!(notification, ClientNotification::CancelledNotification(_)) {
self.cancelled.notify_one();
}
Ok(())
}
fn get_info(&self) -> rmcp::model::ServerInfo {
ServerInfo::default()
}
}
impl ServerHandler for ClosedSinkServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
self.sink.lock().await.replace(context.sink().clone());
Ok(())
}
}
#[derive(Clone)]
struct CancellationServer {
cancelled: Arc<Notify>,
}
impl ServerHandler for CancellationServer {
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.clone())
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
context.cancelled().await;
self.cancelled.notify_one();
Ok(())
}
}
#[derive(Clone)]
struct AbruptServer {
started: Arc<Notify>,
}
impl ServerHandler for AbruptServer {
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.clone())
}
async fn listen(&self, _context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
self.started.notify_one();
std::future::pending().await
}
}
async fn modern_client<S: ServerHandler>(
server: S,
) -> anyhow::Result<rmcp::service::RunningService<rmcp::RoleClient, ()>> {
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
tokio::spawn(async move {
let server = server.serve(server_transport).await?;
server.waiting().await?;
anyhow::Ok(())
});
().serve_with_lifecycle(
client_transport,
rmcp::ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await
.map_err(Into::into)
}
#[tokio::test]
async fn listen_exposes_acknowledged_filter_and_graceful_result() -> anyhow::Result<()> {
let client = modern_client(ToolsOnlyServer).await?;
let mut subscription = client
.listen(
SubscriptionFilter::builder()
.tools_list_changed()
.prompts_list_changed()
.build(),
)
.await?;
assert_eq!(
subscription.acknowledged(),
&SubscriptionFilter::builder().tools_list_changed().build()
);
let notification = tokio::time::timeout(Duration::from_secs(5), subscription.next())
.await??
.expect("tool notification");
assert!(matches!(
notification,
ServerNotification::ToolListChangedNotification(_)
));
assert_eq!(
notification.get_meta().subscription_id(),
Some(subscription.id().clone())
);
assert!(
tokio::time::timeout(Duration::from_secs(5), subscription.next())
.await??
.is_none()
);
let Some(SubscriptionEnd::Graceful(result)) = subscription.end() else {
panic!("expected graceful final result");
};
assert_eq!(
result.meta.subscription_id().as_ref(),
Some(subscription.id())
);
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn typed_subscription_notifications_do_not_reach_handler_callbacks() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
tokio::spawn(async move {
let server = ToolsOnlyServer.serve(server_transport).await?;
server.waiting().await?;
anyhow::Ok(())
});
let tool_changes = Arc::new(AtomicUsize::new(0));
let client = CountingClient {
tool_changes: tool_changes.clone(),
}
.serve_with_lifecycle(
client_transport,
rmcp::ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(subscription.next().await?.is_some());
assert!(subscription.next().await?.is_none());
tokio::task::yield_now().await;
assert_eq!(tool_changes.load(Ordering::Relaxed), 0);
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn discover_lifecycle_allows_subscriptions_with_older_application_version()
-> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
tokio::spawn(async move {
let server = ToolsOnlyServer.serve(server_transport).await?;
server.waiting().await?;
anyhow::Ok(())
});
let client = ()
.serve_with_lifecycle(
client_transport,
rmcp::ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2025_11_25],
},
)
.await?;
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(subscription.next().await?.is_some());
assert!(subscription.next().await?.is_none());
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn concurrent_subscriptions_are_demultiplexed_by_request_id() -> anyhow::Result<()> {
let client = modern_client(ToolsAndPromptsServer).await?;
let (tools, prompts) = tokio::join!(
client.listen(SubscriptionFilter::builder().tools_list_changed().build()),
client.listen(SubscriptionFilter::builder().prompts_list_changed().build())
);
let mut tools = tools?;
let mut prompts = prompts?;
let tool_notification = tools.next().await?.expect("tool notification");
let prompt_notification = prompts.next().await?.expect("prompt notification");
assert!(matches!(
tool_notification,
ServerNotification::ToolListChangedNotification(_)
));
assert!(matches!(
prompt_notification,
ServerNotification::PromptListChangedNotification(_)
));
assert_ne!(tools.id(), prompts.id());
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn stdio_cancellation_sends_cancelled_for_the_listen_request() -> anyhow::Result<()> {
let cancelled = Arc::new(Notify::new());
let client = modern_client(CancellationServer {
cancelled: cancelled.clone(),
})
.await?;
let mut subscription = client.listen(SubscriptionFilter::new()).await?;
subscription.cancel().await?;
assert!(matches!(
subscription.end(),
Some(SubscriptionEnd::Cancelled)
));
tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?;
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn resource_updates_are_filtered_by_exact_uri_membership() -> anyhow::Result<()> {
let client = modern_client(ResourceSubscriptionServer).await?;
let mut subscription = client
.listen(
SubscriptionFilter::builder()
.resource_subscription("file:///accepted")
.build(),
)
.await?;
let notification = subscription.next().await?.expect("resource update");
let ServerNotification::ResourceUpdatedNotification(update) = notification else {
panic!("expected resource update");
};
assert_eq!(update.params.uri, "file:///accepted");
assert!(subscription.next().await?.is_none());
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn stdio_server_cancellation_ends_the_matching_subscription() -> anyhow::Result<()> {
let client = modern_client(RemoteCancellationServer).await?;
let mut subscription = client.listen(SubscriptionFilter::new()).await?;
assert!(subscription.next().await?.is_none());
assert!(matches!(
subscription.end(),
Some(SubscriptionEnd::Cancelled)
));
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn slow_consumer_reports_subscription_lag() -> anyhow::Result<()> {
let client = modern_client(FloodServer).await?;
let mut subscription = client
.listen_with_capacity(
SubscriptionFilter::builder().tools_list_changed().build(),
NonZeroUsize::MIN,
)
.await?;
tokio::time::sleep(Duration::from_millis(50)).await;
while subscription.next().await?.is_some() {}
assert!(
matches!(
subscription.end(),
Some(SubscriptionEnd::Lagged { capacity: 1 })
),
"unexpected end: {:?}",
subscription.end()
);
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn sink_rejects_notifications_after_graceful_completion() -> anyhow::Result<()> {
let sink = Arc::new(Mutex::new(None));
let client = modern_client(ClosedSinkServer { sink: sink.clone() }).await?;
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(subscription.next().await?.is_none());
let sink = sink.lock().await.clone().expect("captured sink");
assert!(matches!(
sink.notify_tool_list_changed().await,
Err(SubscriptionSendError::SubscriptionClosed)
));
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn client_rejects_notifications_outside_the_acknowledged_filter() -> anyhow::Result<()> {
let client = modern_client(LeakyServer).await?;
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(matches!(
subscription.next().await,
Err(rmcp::ServiceError::UnexpectedResponse)
));
assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt)));
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn malformed_acknowledgment_cancels_pending_listen_request() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
let cancelled = Arc::new(Notify::new());
let server_cancelled = cancelled.clone();
tokio::spawn(async move {
let server = MalformedAcknowledgmentServer {
cancelled: server_cancelled,
}
.serve(server_transport)
.await?;
server.waiting().await?;
anyhow::Ok(())
});
let client = ()
.serve_with_lifecycle(
client_transport,
rmcp::ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
assert!(matches!(
client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await,
Err(rmcp::ServiceError::UnexpectedResponse)
));
tokio::time::timeout(Duration::from_secs(5), cancelled.notified()).await?;
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn transport_close_without_final_result_is_abrupt() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
let started = Arc::new(Notify::new());
let server_started = started.clone();
tokio::spawn(async move {
let server = AbruptServer {
started: server_started.clone(),
}
.serve(server_transport)
.await?;
server_started.notified().await;
server.cancel().await?;
anyhow::Ok(())
});
let client = ()
.serve_with_lifecycle(
client_transport,
rmcp::ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
let mut subscription = client.listen(SubscriptionFilter::new()).await?;
assert!(
tokio::time::timeout(Duration::from_secs(5), subscription.next())
.await??
.is_none()
);
assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt)));
Ok(())
}

View file

@ -0,0 +1,237 @@
use rmcp::model::{
ClientJsonRpcMessage, ClientRequest, GetMeta, JsonRpcNotification, JsonRpcRequest,
NotificationMetaObject, RequestId, RequestMetaObject, ServerJsonRpcMessage, ServerNotification,
SubscriptionFilter, SubscriptionsAcknowledgedNotification,
SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequest,
SubscriptionsListenRequestParams, SubscriptionsListenResult, SubscriptionsListenResultMeta,
};
use serde_json::json;
#[test]
fn subscription_filter_serializes_only_opted_in_notifications() {
let filter = SubscriptionFilter::builder()
.tools_list_changed()
.resource_subscription("file:///one")
.resource_subscription("file:///two")
.build();
assert_eq!(
serde_json::to_value(filter).expect("serialize filter"),
json!({
"toolsListChanged": true,
"resourceSubscriptions": ["file:///one", "file:///two"],
})
);
}
#[test]
fn subscription_filter_subset_is_order_independent_and_ignores_false_flags() {
let requested = SubscriptionFilter::builder()
.tools_list_changed()
.resource_subscriptions(["file:///one", "file:///two"])
.build();
let mut accepted = SubscriptionFilter::builder()
.resource_subscriptions(["file:///two", "file:///one"])
.build();
accepted.tools_list_changed = Some(false);
assert!(accepted.is_subset_of(&requested));
}
#[test]
fn subscription_filter_omits_empty_resource_intersection() {
let requested = SubscriptionFilter::builder()
.resource_subscription("file:///requested")
.build();
let accepted = SubscriptionFilter::builder()
.resource_subscription("file:///different")
.build();
assert_eq!(
serde_json::to_value(requested.intersection(&accepted)).expect("serialize intersection"),
json!({})
);
}
#[test]
fn listen_request_round_trips_required_fields_and_arbitrary_metadata() {
let mut request = SubscriptionsListenRequest::new(SubscriptionsListenRequestParams::new(
SubscriptionFilter::builder().prompts_list_changed().build(),
));
let mut meta = RequestMetaObject::new();
meta.insert("com.example/request".into(), json!("value"));
request.extensions.insert(meta);
let message = ClientJsonRpcMessage::request(
ClientRequest::SubscriptionsListenRequest(request),
RequestId::String("subscription-1".into()),
);
let value = serde_json::to_value(&message).expect("serialize listen request");
assert_eq!(
value,
json!({
"jsonrpc": "2.0",
"id": "subscription-1",
"method": "subscriptions/listen",
"params": {
"_meta": {
"com.example/request": "value",
},
"notifications": {
"promptsListChanged": true,
},
},
})
);
let round_trip: ClientJsonRpcMessage =
serde_json::from_value(value).expect("deserialize listen request");
let ClientJsonRpcMessage::Request(JsonRpcRequest { request, .. }) = round_trip else {
panic!("expected request");
};
let ClientRequest::SubscriptionsListenRequest(request) = request else {
panic!("expected subscriptions/listen request");
};
assert_eq!(
request
.extensions
.get::<RequestMetaObject>()
.and_then(|meta| meta.get("com.example/request")),
Some(&json!("value"))
);
}
#[test]
fn acknowledged_notification_round_trips_numeric_subscription_id_and_metadata() {
let mut notification = SubscriptionsAcknowledgedNotification::new(
SubscriptionsAcknowledgedNotificationParams::new(
SubscriptionFilter::builder()
.resources_list_changed()
.build(),
),
);
let mut meta = NotificationMetaObject::new();
meta.set_subscription_id(RequestId::Number(7));
meta.insert("com.example/notification".into(), json!(true));
notification.extensions.insert(meta);
let message = ServerJsonRpcMessage::notification(
ServerNotification::SubscriptionsAcknowledgedNotification(notification),
);
let value = serde_json::to_value(&message).expect("serialize acknowledgment");
assert_eq!(
value,
json!({
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": 7,
"com.example/notification": true,
},
"notifications": {
"resourcesListChanged": true,
},
},
})
);
let round_trip: ServerJsonRpcMessage =
serde_json::from_value(value).expect("deserialize acknowledgment");
let ServerJsonRpcMessage::Notification(JsonRpcNotification { notification, .. }) = round_trip
else {
panic!("expected notification");
};
assert_eq!(
notification.get_meta().subscription_id(),
Some(RequestId::Number(7))
);
assert_eq!(
notification.get_meta().get("com.example/notification"),
Some(&json!(true))
);
}
#[test]
fn listen_result_requires_matching_string_subscription_id_and_preserves_metadata() {
let mut meta = SubscriptionsListenResultMeta::new(RequestId::String("subscription-2".into()));
meta.insert("com.example/result".into(), json!({ "reason": "shutdown" }));
let result = SubscriptionsListenResult::new(meta);
let value = serde_json::to_value(&result).expect("serialize listen result");
assert_eq!(
value,
json!({
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/subscriptionId": "subscription-2",
"com.example/result": {
"reason": "shutdown",
},
},
})
);
let round_trip: SubscriptionsListenResult =
serde_json::from_value(value).expect("deserialize listen result");
assert_eq!(
round_trip.meta.subscription_id(),
Some(RequestId::String("subscription-2".into()))
);
assert_eq!(
round_trip.meta.get("com.example/result"),
Some(&json!({ "reason": "shutdown" }))
);
}
#[test]
fn listen_result_meta_returns_none_after_required_id_is_removed() {
let mut meta = SubscriptionsListenResultMeta::new(RequestId::Number(1));
meta.remove("io.modelcontextprotocol/subscriptionId");
assert_eq!(meta.subscription_id(), None);
}
#[cfg(feature = "schemars")]
#[test]
fn subscription_schemas_mark_only_draft_required_fields_as_required() {
let request_schema =
serde_json::to_value(schemars::schema_for!(SubscriptionsListenRequestParams))
.expect("request schema");
let filter_schema =
serde_json::to_value(schemars::schema_for!(SubscriptionFilter)).expect("filter schema");
let acknowledgment_schema = serde_json::to_value(schemars::schema_for!(
SubscriptionsAcknowledgedNotificationParams
))
.expect("acknowledgment schema");
let result_schema = serde_json::to_value(schemars::schema_for!(SubscriptionsListenResult))
.expect("result schema");
assert_eq!(
request_schema["required"],
json!(["_meta", "notifications"])
);
assert_eq!(
request_schema["properties"]["_meta"]["required"],
json!([
"io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/clientCapabilities"
])
);
assert!(filter_schema.get("required").is_none());
assert_eq!(
filter_schema["properties"]["toolsListChanged"]["type"],
"boolean"
);
assert_eq!(
filter_schema["properties"]["resourceSubscriptions"]["type"],
"array"
);
assert_eq!(acknowledgment_schema["required"], json!(["notifications"]));
assert_eq!(
acknowledgment_schema["properties"]["_meta"]["$ref"],
"#/$defs/NotificationMetaObject"
);
assert_eq!(result_schema["required"], json!(["resultType", "_meta"]));
}

View file

@ -0,0 +1,310 @@
#![cfg(all(
not(feature = "local"),
feature = "client",
feature = "server",
feature = "transport-streamable-http-client-reqwest",
feature = "transport-streamable-http-server"
))]
use std::{
borrow::Cow,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use rmcp::{
ClientLifecycleMode, ClientServiceExt, ServerHandler,
model::{
ClientInfo, ClientRequest, ListToolsRequest, ProtocolVersion, RequestMetaObject,
ServerCapabilities, ServerInfo, ServerNotification, SubscriptionFilter,
},
service::{PeerRequestOptions, SubscriptionContext, SubscriptionEnd},
transport::{
StreamableHttpClientTransport,
streamable_http_client::StreamableHttpClientTransportConfig,
streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
},
};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
#[derive(Clone)]
struct HttpSubscriptionServer {
cancelled: Arc<Notify>,
started: Arc<Notify>,
ending: ServerEnding,
}
#[derive(Clone, Copy)]
enum ServerEnding {
ClientCancellation,
Graceful,
Abrupt,
}
impl ServerHandler for HttpSubscriptionServer {
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(&[ProtocolVersion::V_2026_07_28, ProtocolVersion::V_2025_11_25])
}
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build(),
)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
Some(requested.supported_by(&self.get_info().capabilities))
}
async fn listen(&self, context: SubscriptionContext) -> Result<(), rmcp::ErrorData> {
context
.sink()
.notify_tool_list_changed()
.await
.expect("send tool notification");
self.started.notify_one();
match self.ending {
ServerEnding::Graceful => Ok(()),
ServerEnding::ClientCancellation => {
context.cancelled().await;
self.cancelled.notify_one();
Ok(())
}
ServerEnding::Abrupt => std::future::pending().await,
}
}
}
async fn spawn_server(
ending: ServerEnding,
) -> (
String,
CancellationToken,
Arc<Notify>,
Arc<Notify>,
Arc<AtomicUsize>,
) {
let cancellation_token = CancellationToken::new();
let subscription_cancelled = Arc::new(Notify::new());
let subscription_started = Arc::new(Notify::new());
let server = HttpSubscriptionServer {
cancelled: subscription_cancelled.clone(),
started: subscription_started.clone(),
ending,
};
let service: StreamableHttpService<HttpSubscriptionServer, LocalSessionManager> =
StreamableHttpService::new(
move || Ok(server.clone()),
Default::default(),
StreamableHttpServerConfig::default()
.with_legacy_session_mode(true)
.with_json_response(true)
.with_sse_keep_alive(Some(Duration::from_millis(50)))
.with_cancellation_token(cancellation_token.child_token()),
);
let get_requests = Arc::new(AtomicUsize::new(0));
let observed_get_requests = get_requests.clone();
let router =
axum::Router::new()
.nest_service("/mcp", service)
.layer(axum::middleware::from_fn(
move |request: axum::extract::Request, next: axum::middleware::Next| {
let observed_get_requests = observed_get_requests.clone();
async move {
if request.method() == axum::http::Method::GET {
observed_get_requests.fetch_add(1, Ordering::Relaxed);
}
next.run(request).await
}
},
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener");
let address = listener.local_addr().expect("listener address");
tokio::spawn({
let cancellation_token = cancellation_token.clone();
async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await })
.await;
}
});
(
format!("http://{address}/mcp"),
cancellation_token,
subscription_cancelled,
subscription_started,
get_requests,
)
}
#[tokio::test]
async fn modern_http_listen_uses_post_stream_and_cancels_by_closing_it() -> anyhow::Result<()> {
let (url, server_ct, subscription_cancelled, _, get_requests) =
spawn_server(ServerEnding::ClientCancellation).await;
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(url.clone()),
);
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
assert_eq!(get_requests.load(Ordering::Relaxed), 0);
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(matches!(
subscription.next().await?.expect("tool notification"),
ServerNotification::ToolListChangedNotification(_)
));
let mut older_version = RequestMetaObject::new();
older_version.set_protocol_version(ProtocolVersion::V_2025_11_25);
client
.send_request_with_option(
ClientRequest::ListToolsRequest(ListToolsRequest {
method: Default::default(),
params: None,
extensions: Default::default(),
}),
PeerRequestOptions::no_options().with_meta(older_version),
)
.await?
.await_response()
.await?;
subscription.cancel().await?;
tokio::time::timeout(Duration::from_secs(5), subscription_cancelled.notified()).await?;
client.cancel().await?;
server_ct.cancel();
Ok(())
}
#[tokio::test]
async fn modern_http_graceful_close_returns_final_listen_result() -> anyhow::Result<()> {
let (url, server_ct, _, _, _) = spawn_server(ServerEnding::Graceful).await;
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(url),
);
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(subscription.next().await?.is_some());
assert!(subscription.next().await?.is_none());
assert!(matches!(
subscription.end(),
Some(SubscriptionEnd::Graceful(_))
));
client.cancel().await?;
server_ct.cancel();
Ok(())
}
#[tokio::test]
async fn modern_http_stream_close_without_result_is_abrupt() -> anyhow::Result<()> {
let (url, server_ct, _, subscription_started, _) = spawn_server(ServerEnding::Abrupt).await;
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(url),
);
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
},
)
.await?;
let mut subscription = client
.listen(SubscriptionFilter::builder().tools_list_changed().build())
.await?;
assert!(subscription.next().await?.is_some());
subscription_started.notified().await;
server_ct.cancel();
assert!(
tokio::time::timeout(Duration::from_secs(5), subscription.next())
.await??
.is_none()
);
assert!(matches!(subscription.end(), Some(SubscriptionEnd::Abrupt)));
client.cancel().await?;
Ok(())
}
#[tokio::test]
async fn modern_http_lifecycle_stays_sessionless_for_older_application_version()
-> anyhow::Result<()> {
let (url, server_ct, _, _, get_requests) = spawn_server(ServerEnding::ClientCancellation).await;
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(url),
);
let client = ClientInfo::default()
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: vec![ProtocolVersion::V_2025_11_25],
},
)
.await?;
client.list_tools(None).await?;
assert_eq!(get_requests.load(Ordering::Relaxed), 0);
client.cancel().await?;
server_ct.cancel();
Ok(())
}
#[tokio::test]
async fn modern_http_get_and_delete_are_method_not_allowed_in_legacy_session_mode() {
let (url, server_ct, _, _, _) = spawn_server(ServerEnding::ClientCancellation).await;
let client = reqwest::Client::new();
for method in [reqwest::Method::GET, reqwest::Method::DELETE] {
let response = client
.request(method, &url)
.header("Accept", "text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.send()
.await
.expect("request");
assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
response
.headers()
.get(reqwest::header::ALLOW)
.and_then(|value| value.to_str().ok()),
Some("POST")
);
}
server_ct.cancel();
}