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
10 changed files with 3617 additions and 223 deletions

View file

@ -66,6 +66,10 @@ impl<H: ClientHandler> Service<RoleClient> for H {
ServerNotification::PromptListChangedNotification(_notification_no_param) => {
self.on_prompt_list_changed(context).await
}
ServerNotification::SubscriptionsAcknowledgedNotification(notification) => {
self.on_subscriptions_acknowledged(notification.params, context)
.await
}
ServerNotification::TaskStatusNotification(notification) => {
self.on_task_status(notification.params, context).await
}
@ -237,6 +241,13 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_subscriptions_acknowledged(
&self,
params: SubscriptionsAcknowledgedNotificationParams,
context: NotificationContext<RoleClient>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_task_status(
&self,
@ -365,6 +376,14 @@ macro_rules! impl_client_handler_for_wrapper {
(**self).on_prompt_list_changed(context)
}
fn on_subscriptions_acknowledged(
&self,
params: SubscriptionsAcknowledgedNotificationParams,
context: NotificationContext<RoleClient>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
(**self).on_subscriptions_acknowledged(params, context)
}
fn on_task_status(
&self,
params: TaskStatusNotificationParam,

View file

@ -7,7 +7,7 @@ use crate::{
model::{TaskSupport, *},
service::{
MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole,
negotiate_protocol_version,
SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle,
},
};
@ -69,6 +69,8 @@ impl<H: ServerHandler> Service<RoleServer> for H {
));
}
}
let legacy_request =
uses_legacy_lifecycle(protocol_version.as_ref(), requires_request_metadata);
let result = match request {
ClientRequest::InitializeRequest(request) => self
.initialize(request.params, context)
@ -79,7 +81,11 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.await
.map(ServerResult::DiscoverResult),
ClientRequest::PingRequest(_request) => {
self.ping(context).await.map(ServerResult::empty)
if !legacy_request {
Err(McpError::method_not_found::<PingRequestMethod>())
} else {
self.ping(context).await.map(ServerResult::empty)
}
}
ClientRequest::CompleteRequest(request) => self
.complete(request.params, context)
@ -109,14 +115,60 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.read_resource(request.params, context)
.await
.map(ServerResult::from),
ClientRequest::SubscribeRequest(request) => self
.subscribe(request.params, context)
.await
.map(ServerResult::empty),
ClientRequest::UnsubscribeRequest(request) => self
.unsubscribe(request.params, context)
.await
.map(ServerResult::empty),
ClientRequest::SubscriptionsListenRequest(request) => {
if legacy_request {
Err(McpError::method_not_found::<SubscriptionsListenRequestMethod>())
} else {
let requested = request.params.notifications;
let Some(candidate) = self.accepted_subscription_filter(&requested) else {
return Err(
McpError::method_not_found::<SubscriptionsListenRequestMethod>(),
);
};
let advertised = requested.supported_by(&self.get_info().capabilities);
let handler_accepted = requested.intersection(&candidate);
let accepted = handler_accepted.intersection(&advertised);
if accepted != handler_accepted {
tracing::debug!(
requested_resource_count = requested
.resource_subscriptions
.as_ref()
.map_or(0, Vec::len),
accepted_resource_count =
accepted.resource_subscriptions.as_ref().map_or(0, Vec::len),
"subscription filter reduced to advertised server capabilities"
);
}
let subscription_id = context.id.clone();
let subscription =
SubscriptionContext::establish(context, requested, accepted).await?;
// The integrated draft schema defines a final result for graceful
// server teardown; explicit stdio cancellation remains a notification.
self.listen(subscription).await.map(|()| {
ServerResult::SubscriptionsListenResult(
SubscriptionsListenResult::complete(subscription_id),
)
})
}
}
ClientRequest::SubscribeRequest(request) => {
if !legacy_request {
Err(McpError::method_not_found::<SubscribeRequestMethod>())
} else {
self.subscribe(request.params, context)
.await
.map(ServerResult::empty)
}
}
ClientRequest::UnsubscribeRequest(request) => {
if !legacy_request {
Err(McpError::method_not_found::<UnsubscribeRequestMethod>())
} else {
self.unsubscribe(request.params, context)
.await
.map(ServerResult::empty)
}
}
ClientRequest::CallToolRequest(request) => {
let is_task = request.params.task.is_some();
@ -335,6 +387,36 @@ macro_rules! server_handler_methods {
McpError::method_not_found::<ReadResourceRequestMethod>(),
))
}
/// Return the subset of a requested notification filter this server accepts.
///
/// Returning `None` leaves `subscriptions/listen` unimplemented. The SDK
/// intersects the returned filter with both `requested` and the notification
/// capabilities advertised by [`Self::get_info`] before acknowledging it.
/// Categories that were not requested or advertised are always removed.
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
None
}
/// Run one established subscription until it is cancelled or closed gracefully.
///
/// The SDK sends the acknowledgment before invoking this method. Returning
/// `Ok(())` sends the final [`SubscriptionsListenResult`] defined by the
/// integrated draft schema, marking graceful server teardown. Explicit
/// stdio cancellation uses `notifications/cancelled` instead.
fn listen(
&self,
context: SubscriptionContext,
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
async move {
context.cancelled().await;
Ok(())
}
}
#[deprecated(
note = "resources/subscribe is legacy-only; implement accepted_subscription_filter and listen for protocol version 2026-07-28"
)]
fn subscribe(
&self,
request: SubscribeRequestParams,
@ -342,6 +424,9 @@ macro_rules! server_handler_methods {
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
std::future::ready(Err(McpError::method_not_found::<SubscribeRequestMethod>()))
}
#[deprecated(
note = "resources/unsubscribe is legacy-only; subscriptions/listen is cancelled through its request lifecycle"
)]
fn unsubscribe(
&self,
request: UnsubscribeRequestParams,
@ -604,6 +689,20 @@ macro_rules! impl_server_handler_for_wrapper {
(**self).read_resource(request, context)
}
fn accepted_subscription_filter(
&self,
requested: &SubscriptionFilter,
) -> Option<SubscriptionFilter> {
(**self).accepted_subscription_filter(requested)
}
fn listen(
&self,
context: SubscriptionContext,
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
(**self).listen(context)
}
fn subscribe(
&self,
request: SubscribeRequestParams,

View file

@ -3,8 +3,10 @@
#![expect(deprecated)]
use std::{
borrow::Cow,
collections::hash_map::RandomState,
hash::{BuildHasher, Hasher},
ops::{Deref, DerefMut},
sync::Arc,
sync::{Arc, OnceLock},
};
mod annotated;
mod capabilities;
@ -558,6 +560,8 @@ pub struct ErrorData {
}
impl ErrorData {
const TRANSPORT_CLOSED_MARKER: &str = "io.modelcontextprotocol/transportClosed";
pub fn new(
code: ErrorCode,
message: impl Into<Cow<'static, str>>,
@ -616,6 +620,33 @@ impl ErrorData {
pub fn internal_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
Self::new(ErrorCode::INTERNAL_ERROR, message, data)
}
#[cfg(feature = "transport-streamable-http-client")]
pub(crate) fn transport_closed(message: impl Into<Cow<'static, str>>) -> Self {
let mut data = JsonObject::new();
data.insert(
Self::TRANSPORT_CLOSED_MARKER.to_owned(),
Value::from(Self::transport_closed_token()),
);
Self::internal_error(message, Some(Value::Object(data)))
}
pub(crate) fn is_transport_closed(&self) -> bool {
self.data
.as_ref()
.and_then(|data| data.get(Self::TRANSPORT_CLOSED_MARKER))
.and_then(Value::as_u64)
== Some(Self::transport_closed_token())
}
fn transport_closed_token() -> u64 {
static TOKEN: OnceLock<u64> = OnceLock::new();
*TOKEN.get_or_init(|| {
let mut hasher = RandomState::new().build_hasher();
hasher.write(b"rmcp transport-closed marker");
hasher.finish()
})
}
}
/// Represents any JSON-RPC message that can be sent or received.
@ -1661,6 +1692,9 @@ impl RequestParamsMeta for SubscribeRequestParams {
pub type SubscribeRequestParam = SubscribeRequestParams;
/// Request to subscribe to resource updates
#[deprecated(
note = "resources/subscribe is legacy-only; use subscriptions/listen for protocol version 2026-07-28"
)]
pub type SubscribeRequest = Request<SubscribeRequestMethod, SubscribeRequestParams>;
const_string!(UnsubscribeRequestMethod = "resources/unsubscribe");
@ -1701,6 +1735,9 @@ impl RequestParamsMeta for UnsubscribeRequestParams {
pub type UnsubscribeRequestParam = UnsubscribeRequestParams;
/// Request to unsubscribe from resource updates
#[deprecated(
note = "resources/unsubscribe is legacy-only; cancel the subscriptions/listen request for protocol version 2026-07-28"
)]
pub type UnsubscribeRequest = Request<UnsubscribeRequestMethod, UnsubscribeRequestParams>;
const_string!(ResourceUpdatedNotificationMethod = "notifications/resources/updated");
@ -1730,6 +1767,390 @@ impl ResourceUpdatedNotificationParam {
pub type ResourceUpdatedNotification =
Notification<ResourceUpdatedNotificationMethod, ResourceUpdatedNotificationParam>;
// =============================================================================
// SUBSCRIPTIONS
// =============================================================================
/// Notification categories a client opts in to on a `subscriptions/listen` stream.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionFilter {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "bool"))]
pub tools_list_changed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "bool"))]
pub prompts_list_changed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "bool"))]
pub resources_list_changed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "Vec<String>"))]
pub resource_subscriptions: Option<Vec<String>>,
}
impl SubscriptionFilter {
/// Create an empty filter that opts in to no notifications.
pub fn new() -> Self {
Self::default()
}
/// Create a builder for a subscription filter.
pub fn builder() -> SubscriptionFilterBuilder {
SubscriptionFilterBuilder::default()
}
/// Return the subset present in both filters.
pub fn intersection(&self, other: &Self) -> Self {
let resource_subscriptions = self
.resource_subscriptions
.as_ref()
.and_then(|requested| {
other.resource_subscriptions.as_ref().map(|accepted| {
requested
.iter()
.filter(|uri| accepted.contains(uri))
.cloned()
.collect()
})
})
.filter(|uris: &Vec<String>| !uris.is_empty());
Self {
tools_list_changed: (self.tools_list_changed == Some(true)
&& other.tools_list_changed == Some(true))
.then_some(true),
prompts_list_changed: (self.prompts_list_changed == Some(true)
&& other.prompts_list_changed == Some(true))
.then_some(true),
resources_list_changed: (self.resources_list_changed == Some(true)
&& other.resources_list_changed == Some(true))
.then_some(true),
resource_subscriptions,
}
}
/// Return whether this filter accepts only notifications requested by `other`.
pub fn is_subset_of(&self, other: &Self) -> bool {
let booleans_are_subset = [
(self.tools_list_changed, other.tools_list_changed),
(self.prompts_list_changed, other.prompts_list_changed),
(self.resources_list_changed, other.resources_list_changed),
]
.into_iter()
.all(|(accepted, requested)| accepted != Some(true) || requested == Some(true));
let resources_are_subset = self.resource_subscriptions.as_ref().is_none_or(|accepted| {
accepted.iter().all(|uri| {
other
.resource_subscriptions
.as_ref()
.is_some_and(|requested| requested.contains(uri))
})
});
booleans_are_subset && resources_are_subset
}
/// Return the requested notification types advertised by server capabilities.
pub fn supported_by(&self, capabilities: &ServerCapabilities) -> Self {
Self {
tools_list_changed: (self.tools_list_changed == Some(true)
&& capabilities
.tools
.as_ref()
.is_some_and(|tools| tools.list_changed == Some(true)))
.then_some(true),
prompts_list_changed: (self.prompts_list_changed == Some(true)
&& capabilities
.prompts
.as_ref()
.is_some_and(|prompts| prompts.list_changed == Some(true)))
.then_some(true),
resources_list_changed: (self.resources_list_changed == Some(true)
&& capabilities
.resources
.as_ref()
.is_some_and(|resources| resources.list_changed == Some(true)))
.then_some(true),
resource_subscriptions: capabilities
.resources
.as_ref()
.is_some_and(|resources| resources.subscribe == Some(true))
.then(|| self.resource_subscriptions.clone())
.flatten(),
}
}
}
/// Builder for [`SubscriptionFilter`].
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct SubscriptionFilterBuilder {
filter: SubscriptionFilter,
}
impl SubscriptionFilterBuilder {
/// Opt in to `notifications/tools/list_changed`.
pub fn tools_list_changed(mut self) -> Self {
self.filter.tools_list_changed = Some(true);
self
}
/// Opt in to `notifications/prompts/list_changed`.
pub fn prompts_list_changed(mut self) -> Self {
self.filter.prompts_list_changed = Some(true);
self
}
/// Opt in to `notifications/resources/list_changed`.
pub fn resources_list_changed(mut self) -> Self {
self.filter.resources_list_changed = Some(true);
self
}
/// Opt in to updates for all supplied resource URIs.
pub fn resource_subscriptions(
mut self,
uris: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.filter.resource_subscriptions = Some(uris.into_iter().map(Into::into).collect());
self
}
/// Add one resource URI to the update subscription set.
pub fn resource_subscription(mut self, uri: impl Into<String>) -> Self {
self.filter
.resource_subscriptions
.get_or_insert_default()
.push(uri.into());
self
}
/// Build the filter.
pub fn build(self) -> SubscriptionFilter {
self.filter
}
}
const_string!(SubscriptionsListenRequestMethod = "subscriptions/listen");
#[cfg(feature = "schemars")]
fn subscriptions_listen_request_meta_schema(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
let progress_token = generator.subschema_for::<ProgressToken>();
let client_info = generator.subschema_for::<Implementation>();
let client_capabilities = generator.subschema_for::<ClientCapabilities>();
let log_level = generator.subschema_for::<LoggingLevel>();
schemars::json_schema!({
"type": "object",
"properties": {
"progressToken": progress_token,
"io.modelcontextprotocol/protocolVersion": {
"type": "string",
},
"io.modelcontextprotocol/clientInfo": client_info,
"io.modelcontextprotocol/clientCapabilities": client_capabilities,
"io.modelcontextprotocol/logLevel": log_level,
},
"required": RequestMetaObject::DRAFT_REQUIRED_KEYS,
"additionalProperties": true,
})
}
/// Parameters for opening a long-lived notification subscription.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionsListenRequestParams {
/// Protocol-level metadata. Required by the draft wire schema.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schemars",
schemars(required, schema_with = "subscriptions_listen_request_meta_schema")
)]
pub meta: Option<RequestMetaObject>,
/// Notification categories requested for this stream.
pub notifications: SubscriptionFilter,
}
impl SubscriptionsListenRequestParams {
/// Create listen parameters for a notification filter.
pub fn new(notifications: SubscriptionFilter) -> Self {
Self {
meta: None,
notifications,
}
}
/// Set protocol-level request metadata.
pub fn with_meta(mut self, meta: RequestMetaObject) -> Self {
self.meta = Some(meta);
self
}
}
impl RequestParamsMeta for SubscriptionsListenRequestParams {
fn meta(&self) -> Option<&RequestMetaObject> {
self.meta.as_ref()
}
fn meta_mut(&mut self) -> &mut Option<RequestMetaObject> {
&mut self.meta
}
}
/// Request that opens a long-lived notification subscription.
pub type SubscriptionsListenRequest =
Request<SubscriptionsListenRequestMethod, SubscriptionsListenRequestParams>;
const SUBSCRIPTION_ID_META_KEY: &str = "io.modelcontextprotocol/subscriptionId";
/// Metadata on the final result of a `subscriptions/listen` request.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(transparent)]
#[non_exhaustive]
pub struct SubscriptionsListenResultMeta(MetaObject);
impl SubscriptionsListenResultMeta {
/// Create result metadata for the originating listen request.
pub fn new(subscription_id: RequestId) -> Self {
let mut meta = MetaObject::new();
meta.insert(
SUBSCRIPTION_ID_META_KEY.to_owned(),
subscription_id.into_json_value(),
);
Self(meta)
}
/// Return the originating listen request ID, if the metadata remains valid.
pub fn subscription_id(&self) -> Option<RequestId> {
self.0
.get(SUBSCRIPTION_ID_META_KEY)
.and_then(|value| RequestId::deserialize(value).ok())
}
/// Replace the originating listen request ID.
pub fn set_subscription_id(&mut self, subscription_id: RequestId) {
self.0.insert(
SUBSCRIPTION_ID_META_KEY.to_owned(),
subscription_id.into_json_value(),
);
}
}
impl<'de> Deserialize<'de> for SubscriptionsListenResultMeta {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let meta = MetaObject::deserialize(deserializer)?;
let Some(value) = meta.get(SUBSCRIPTION_ID_META_KEY) else {
return Err(serde::de::Error::missing_field(SUBSCRIPTION_ID_META_KEY));
};
RequestId::deserialize(value).map_err(serde::de::Error::custom)?;
Ok(Self(meta))
}
}
impl std::ops::Deref for SubscriptionsListenResultMeta {
type Target = MetaObject;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for SubscriptionsListenResultMeta {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for SubscriptionsListenResultMeta {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("SubscriptionsListenResultMeta")
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let subscription_id = generator.subschema_for::<RequestId>();
schemars::json_schema!({
"type": "object",
"properties": {
"io.modelcontextprotocol/subscriptionId": subscription_id,
},
"required": ["io.modelcontextprotocol/subscriptionId"],
"additionalProperties": true,
})
}
}
/// Final response indicating that a subscription ended gracefully.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionsListenResult {
pub result_type: ResultType,
#[serde(rename = "_meta")]
pub meta: SubscriptionsListenResultMeta,
}
impl SubscriptionsListenResult {
/// Create a completed subscription result.
pub fn new(meta: SubscriptionsListenResultMeta) -> Self {
Self {
result_type: ResultType::COMPLETE,
meta,
}
}
/// Create a completed result for the originating listen request.
pub fn complete(subscription_id: RequestId) -> Self {
Self::new(SubscriptionsListenResultMeta::new(subscription_id))
}
}
const_string!(
SubscriptionsAcknowledgedNotificationMethod = "notifications/subscriptions/acknowledged"
);
/// Parameters reporting the accepted subset of a subscription filter.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SubscriptionsAcknowledgedNotificationParams {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "NotificationMetaObject"))]
pub meta: Option<NotificationMetaObject>,
pub notifications: SubscriptionFilter,
}
impl SubscriptionsAcknowledgedNotificationParams {
/// Create acknowledgment parameters for the accepted filter.
pub fn new(notifications: SubscriptionFilter) -> Self {
Self {
meta: None,
notifications,
}
}
/// Set notification metadata.
pub fn with_meta(mut self, meta: NotificationMetaObject) -> Self {
self.meta = Some(meta);
self
}
}
/// First notification sent on an established subscription stream.
pub type SubscriptionsAcknowledgedNotification = Notification<
SubscriptionsAcknowledgedNotificationMethod,
SubscriptionsAcknowledgedNotificationParams,
>;
// =============================================================================
// PROMPT MANAGEMENT
// =============================================================================
@ -3937,6 +4358,7 @@ ts_union!(
| ListResourcesRequest
| ListResourceTemplatesRequest
| ReadResourceRequest
| SubscriptionsListenRequest
| SubscribeRequest
| UnsubscribeRequest
| CallToolRequest
@ -3961,6 +4383,7 @@ impl ClientRequest {
ClientRequest::ListResourcesRequest(r) => r.method.as_str(),
ClientRequest::ListResourceTemplatesRequest(r) => r.method.as_str(),
ClientRequest::ReadResourceRequest(r) => r.method.as_str(),
ClientRequest::SubscriptionsListenRequest(r) => r.method.as_str(),
ClientRequest::SubscribeRequest(r) => r.method.as_str(),
ClientRequest::UnsubscribeRequest(r) => r.method.as_str(),
ClientRequest::CallToolRequest(r) => r.method.as_str(),
@ -4019,6 +4442,7 @@ ts_union!(
| ResourceListChangedNotification
| ToolListChangedNotification
| PromptListChangedNotification
| SubscriptionsAcknowledgedNotification
| TaskStatusNotification
| CustomNotification;
);
@ -4033,6 +4457,7 @@ ts_union!(
| ListResourcesResult
| ListResourceTemplatesResult
| ReadResourceResult
| SubscriptionsListenResult
| ListToolsResult
| ElicitResult
| CreateTaskResult
@ -4096,6 +4521,19 @@ mod tests {
let _: ResourceReference = ResourceTemplateReference::new("res://x");
}
#[cfg(feature = "transport-streamable-http-client")]
#[test]
fn transport_closed_marker_accepts_only_the_process_local_token() {
let local = ErrorData::transport_closed("closed");
let spoofed = ErrorData::internal_error(
"spoofed",
Some(json!({ "io.modelcontextprotocol/transportClosed": true })),
);
assert!(local.is_transport_closed());
assert!(!spoofed.is_transport_closed());
}
#[test]
fn cancelled_notification_request_id_is_optional_on_wire() {
// None → requestId 생략

View file

@ -197,6 +197,7 @@ variant_extension! {
ListResourcesRequest
ListResourceTemplatesRequest
ReadResourceRequest
SubscriptionsListenRequest
SubscribeRequest
UnsubscribeRequest
CallToolRequest
@ -239,6 +240,7 @@ variant_extension! {
ResourceListChangedNotification
ToolListChangedNotification
PromptListChangedNotification
SubscriptionsAcknowledgedNotification
TaskStatusNotification
CustomNotification
}

View file

@ -85,6 +85,8 @@ pub enum ServiceError {
TransportClosed,
#[error("Unexpected response type")]
UnexpectedResponse,
#[error("subscription consumer lagged behind its {capacity}-message buffer")]
SubscriptionLagged { capacity: usize },
#[error("task cancelled for reason {}", reason.as_deref().unwrap_or("<unknown>"))]
Cancelled { reason: Option<String> },
#[error("request timeout after {}", chrono::Duration::from_std(*timeout).unwrap_or_default())]
@ -128,6 +130,20 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
const IS_CLIENT: bool;
type Info: TransferObject;
type PeerInfo: TransferObject;
#[doc(hidden)]
fn configure_direct_peer(_peer: &Peer<Self>, _info: &Self::Info) {}
#[doc(hidden)]
fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
None
}
}
pub(crate) fn uses_legacy_lifecycle(
protocol_version: Option<&ProtocolVersion>,
uses_discover_lifecycle: bool,
) -> bool {
!uses_discover_lifecycle
&& protocol_version.is_none_or(|version| version < &ProtocolVersion::V_2026_07_28)
}
pub type TxJsonRpcMessage<R> =
@ -340,6 +356,8 @@ impl ProgressNotificationToken for ServerNotification {
type Responder<T> = tokio::sync::oneshot::Sender<T>;
type ProgressTimeoutWatchers = Arc<tokio::sync::RwLock<HashMap<ProgressToken, mpsc::Sender<()>>>>;
type SubscriptionChannel<N> = (mpsc::Sender<N>, usize);
type SubscriptionChannelMap<N> = HashMap<RequestId, SubscriptionChannel<N>>;
/// A handle to a remote request
///
@ -400,10 +418,12 @@ impl<R: ServiceRole> RequestHandle<R> {
has_progress_reset_rx,
)
.await;
self.peer.unregister_subscription(&self.id);
result
}
async fn send_timeout_cancel_notification(&self, reason: &str) {
self.peer.unregister_subscription(&self.id);
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: Some(self.id.clone()),
@ -478,6 +498,7 @@ impl<R: ServiceRole> RequestHandle<R> {
self.progress_reset_rx.is_some(),
)
.await;
self.peer.unregister_subscription(&self.id);
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: Some(self.id),
@ -530,7 +551,6 @@ pub(crate) struct ClientRequestMetadata {
/// For general purpose, call [`Peer::send_request`] or [`Peer::send_notification`] to send message to remote peer.
///
/// To create a cancellable request, call [`Peer::send_request_with_option`].
#[derive(Clone)]
pub struct Peer<R: ServiceRole> {
tx: mpsc::Sender<PeerSinkMessage<R>>,
request_id_provider: Arc<dyn RequestIdProvider>,
@ -539,6 +559,25 @@ pub struct Peer<R: ServiceRole> {
info: Arc<std::sync::RwLock<Option<Arc<R::PeerInfo>>>>,
client_request_metadata: Arc<OnceLock<ClientRequestMetadata>>,
request_metadata_required: Arc<std::sync::atomic::AtomicBool>,
subscription_channels: Arc<std::sync::RwLock<SubscriptionChannelMap<R::PeerNot>>>,
}
impl<R: Clone + ServiceRole> Clone for Peer<R>
where
R::PeerInfo: Clone,
{
fn clone(&self) -> Peer<R> {
Self {
tx: self.tx.clone(),
request_id_provider: self.request_id_provider.clone(),
progress_token_provider: self.progress_token_provider.clone(),
progress_timeout_watchers: self.progress_timeout_watchers.clone(),
info: self.info.clone(),
client_request_metadata: self.client_request_metadata.clone(),
request_metadata_required: self.request_metadata_required.clone(),
subscription_channels: self.subscription_channels.clone(),
}
}
}
impl<R: ServiceRole> std::fmt::Debug for Peer<R> {
@ -610,6 +649,7 @@ impl<R: ServiceRole> Peer<R> {
info: Arc::new(std::sync::RwLock::new(peer_info.map(Arc::new))),
client_request_metadata: Default::default(),
request_metadata_required: Default::default(),
subscription_channels: Default::default(),
},
rx,
)
@ -641,9 +681,19 @@ impl<R: ServiceRole> Peer<R> {
}
pub async fn send_request_with_option(
&self,
request: R::Req,
options: PeerRequestOptions,
) -> Result<RequestHandle<R>, ServiceError> {
self.send_request_with_option_and_subscription(request, options, None)
.await
}
async fn send_request_with_option_and_subscription(
&self,
mut request: R::Req,
options: PeerRequestOptions,
subscription_sender: Option<SubscriptionChannel<R::PeerNot>>,
) -> Result<RequestHandle<R>, ServiceError> {
let id = self.request_id_provider.next_request_id();
let progress_token = self.progress_token_provider.next_progress_token();
@ -670,6 +720,10 @@ impl<R: ServiceRole> Peer<R> {
} else {
None
};
if let Some(channel) = subscription_sender {
self.subscription_channels_write()
.insert(id.clone(), channel);
}
if self
.tx
.send(PeerSinkMessage::Request {
@ -686,6 +740,7 @@ impl<R: ServiceRole> Peer<R> {
.await
.remove(&progress_token);
}
self.unregister_subscription(&id);
return Err(ServiceError::TransportClosed);
}
Ok(RequestHandle {
@ -698,6 +753,67 @@ impl<R: ServiceRole> Peer<R> {
})
}
#[cfg(feature = "client")]
pub(crate) async fn send_subscription_request(
&self,
request: R::Req,
options: PeerRequestOptions,
channel_capacity: usize,
) -> Result<(RequestHandle<R>, mpsc::Receiver<R::PeerNot>), ServiceError> {
let (sender, receiver) = mpsc::channel(channel_capacity);
let handle = self
.send_request_with_option_and_subscription(
request,
options,
Some((sender, channel_capacity)),
)
.await?;
Ok((handle, receiver))
}
fn subscription_sender(&self, id: &RequestId) -> Option<SubscriptionChannel<R::PeerNot>> {
self.subscription_channels_read().get(id).cloned()
}
pub(crate) fn unregister_subscription(&self, id: &RequestId) {
self.subscription_channels_write().remove(id);
}
fn subscription_channels_read(
&self,
) -> std::sync::RwLockReadGuard<'_, SubscriptionChannelMap<R::PeerNot>> {
match self.subscription_channels.read() {
Ok(channels) => channels,
Err(poisoned) => poisoned.into_inner(),
}
}
fn subscription_channels_write(
&self,
) -> std::sync::RwLockWriteGuard<'_, SubscriptionChannelMap<R::PeerNot>> {
match self.subscription_channels.write() {
Ok(channels) => channels,
Err(poisoned) => poisoned.into_inner(),
}
}
pub(crate) fn try_cancel_request(&self, id: RequestId, reason: Option<String>) {
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: Some(id),
reason,
meta: None,
},
method: crate::model::CancelledNotificationMethod,
extensions: Default::default(),
};
let (responder, _receiver) = tokio::sync::oneshot::channel();
let _ = self.tx.try_send(PeerSinkMessage::Notification {
notification: notification.into(),
responder,
});
}
async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) {
let sender = self
.progress_timeout_watchers
@ -999,6 +1115,7 @@ where
E: std::error::Error + Send + Sync + 'static,
{
let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info);
R::configure_direct_peer(&peer, &service.get_info());
serve_inner(service, transport.into_transport(), peer, peer_rx, ct)
}
@ -1274,19 +1391,67 @@ where
..
})) => {
tracing::info!(?notification, "received notification");
// catch cancelled notification
let mut notification = match notification.try_into() {
Ok::<CancelledNotification, _>(cancelled) => {
if let Some(request_id) = &cancelled.params.request_id {
if let Some(ct) = local_ct_pool.remove(request_id) {
tracing::info!(id = %request_id, reason = cancelled.params.reason, "cancelled");
let cancellation_request_id =
if let Some(cancelled) = R::peer_cancelled_params(&notification) {
let request_id = cancelled.request_id.clone();
if let Some(request_id) = request_id.as_ref() {
if R::IS_CLIENT {
if let Some(responder) =
local_responder_pool.remove(request_id)
{
let _ = responder.send(Err(ServiceError::Cancelled {
reason: cancelled.reason.clone(),
}));
}
} else if let Some(ct) = local_ct_pool.remove(request_id) {
tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled");
ct.cancel();
}
}
cancelled.into()
request_id
} else {
None
};
let subscription_id = notification
.get_meta()
.subscription_id()
.or(cancellation_request_id);
if let Some(subscription_id) = subscription_id
&& let Some((sender, capacity)) =
peer.subscription_sender(&subscription_id)
{
match sender.try_send(notification) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
id = %subscription_id,
capacity,
"subscription notification buffer full"
);
if R::IS_CLIENT
&& let Some(responder) =
local_responder_pool.remove(&subscription_id)
{
let _ = responder
.send(Err(ServiceError::SubscriptionLagged { capacity }));
}
peer.unregister_subscription(&subscription_id);
peer.try_cancel_request(
subscription_id,
Some("subscription notification buffer full".to_owned()),
);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
peer.unregister_subscription(&subscription_id);
peer.try_cancel_request(
subscription_id,
Some("subscription notification receiver closed".to_owned()),
);
}
}
Err(notification) => notification,
};
continue;
}
let mut notification = notification;
if let Some(progress_token) = notification.progress_token() {
peer.notify_progress_timeout_watcher(progress_token).await;
}
@ -1331,7 +1496,12 @@ where
continue;
};
if let Some(responder) = local_responder_pool.remove(&id) {
let _response_result = responder.send(Err(ServiceError::McpError(error)));
let service_error = if error.is_transport_closed() {
ServiceError::TransportClosed
} else {
ServiceError::McpError(error)
};
let _response_result = responder.send(Err(service_error));
if let Err(_error) = _response_result {
tracing::warn!(%id, "Error sending response");
}

View file

@ -1,6 +1,6 @@
// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected.
#![expect(deprecated)]
use std::{borrow::Cow, sync::Arc, time::Duration};
use std::{borrow::Cow, num::NonZeroUsize, sync::Arc, time::Duration};
use thiserror::Error;
@ -21,8 +21,9 @@ use crate::{
ReadResourceRequest, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult,
Reference, RequestId, RequestMetaObject, RootsListChangedNotification, ServerInfo,
ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult, SetLevelRequest,
SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, UnsubscribeRequest,
UnsubscribeRequestParams,
SetLevelRequestParams, SubscribeRequest, SubscribeRequestParams, SubscriptionFilter,
SubscriptionsListenRequest, SubscriptionsListenRequestParams, SubscriptionsListenResult,
UnsubscribeRequest, UnsubscribeRequestParams,
},
transport::DynamicTransportError,
};
@ -184,10 +185,298 @@ impl ServiceRole for RoleClient {
type PeerInfo = ServerInfo;
type InitializeError = ClientInitializeError;
const IS_CLIENT: bool = true;
fn configure_direct_peer(peer: &Peer<Self>, info: &Self::Info) {
let Some(server_info) = peer.peer_info() else {
return;
};
if server_info.protocol_version.as_str() < ProtocolVersion::V_2026_07_28.as_str() {
return;
}
peer.set_client_request_metadata(ClientRequestMetadata {
protocol_version: server_info.protocol_version.clone(),
client_info: info.client_info.clone(),
client_capabilities: info.capabilities.clone(),
});
}
fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
match notification {
ServerNotification::CancelledNotification(notification) => Some(&notification.params),
_ => None,
}
}
}
pub type ServerSink = Peer<RoleClient>;
/// Default number of notifications buffered for one subscription.
pub const DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY: usize = 64;
/// How a client-side subscription stream ended.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum SubscriptionEnd {
/// The server returned a final `SubscriptionsListenResult`.
Graceful(SubscriptionsListenResult),
/// The transport closed without a final result. Call `Peer::listen` again
/// after reconnecting; subscription streams are not resumable.
Abrupt,
/// The subscription was explicitly cancelled by either peer.
Cancelled,
/// The consumer did not drain notifications before the channel filled.
Lagged { capacity: usize },
}
/// Handle for one active `subscriptions/listen` request.
#[derive(Debug)]
pub struct Subscription {
id: RequestId,
acknowledged: SubscriptionFilter,
notifications: tokio::sync::mpsc::Receiver<ServerNotification>,
request: Option<RequestHandle<RoleClient>>,
end: Option<SubscriptionEnd>,
}
type SubscriptionResponse =
Result<Result<ServerResult, ServiceError>, tokio::sync::oneshot::error::RecvError>;
struct PendingSubscriptionRequest {
handle: Option<RequestHandle<RoleClient>>,
}
impl PendingSubscriptionRequest {
fn new(handle: RequestHandle<RoleClient>) -> Self {
Self {
handle: Some(handle),
}
}
async fn recv(&mut self) -> Option<SubscriptionResponse> {
let handle = self.handle.as_mut()?;
Some((&mut handle.rx).await)
}
fn take(&mut self) -> Option<RequestHandle<RoleClient>> {
self.handle.take()
}
fn unregister(&self, id: &RequestId) {
if let Some(handle) = self.handle.as_ref() {
handle.peer.unregister_subscription(id);
}
}
fn disarm(&mut self) {
self.handle.take();
}
async fn cancel(&mut self, reason: &'static str) {
if let Some(handle) = self.handle.take() {
let _ = handle.cancel(Some(reason.to_owned())).await;
}
}
}
impl Drop for PendingSubscriptionRequest {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
handle.peer.unregister_subscription(&handle.id);
handle.peer.try_cancel_request(
handle.id,
Some("subscription establishment cancelled".to_owned()),
);
}
}
impl Subscription {
/// Return the originating listen request ID.
pub fn id(&self) -> &RequestId {
&self.id
}
/// Return the notification filter accepted by the server.
pub fn acknowledged(&self) -> &SubscriptionFilter {
&self.acknowledged
}
/// Return the terminal state after this subscription has ended.
pub fn end(&self) -> Option<&SubscriptionEnd> {
self.end.as_ref()
}
/// Receive the next notification, or `None` after the subscription ends.
///
/// A graceful final result and an abrupt transport close are distinguished
/// through [`Self::end`].
///
/// # Errors
///
/// Returns a service or protocol error when the stream carries an invalid
/// message, an unexpected final result, or another request failure.
pub async fn next(&mut self) -> Result<Option<ServerNotification>, ServiceError> {
if self.end.is_some() {
return Ok(None);
}
let Some(request) = self.request.as_mut() else {
self.end = Some(SubscriptionEnd::Abrupt);
return Ok(None);
};
tokio::select! {
biased;
notification = self.notifications.recv() => {
let Some(notification) = notification else {
let response = (&mut request.rx).await;
return self.handle_response(response);
};
if let ServerNotification::CancelledNotification(cancelled) = &notification {
if cancelled.params.request_id.as_ref() != Some(&self.id) {
self.cancel_as_abrupt("subscription cancellation ID mismatch")
.await;
return Err(ServiceError::UnexpectedResponse);
}
self.finish(SubscriptionEnd::Cancelled);
return Ok(None);
}
if notification.get_meta().subscription_id().as_ref() != Some(&self.id) {
self.cancel_as_abrupt("subscription notification ID mismatch")
.await;
return Err(ServiceError::UnexpectedResponse);
}
if !self.accepts(&notification) {
self.cancel_as_abrupt(
"subscription notification was outside the acknowledged filter",
)
.await;
return Err(ServiceError::UnexpectedResponse);
}
Ok(Some(notification))
}
response = &mut request.rx => {
self.handle_response(response)
}
}
}
/// Cancel this subscription.
///
/// # Errors
///
/// Returns a transport error when the cancellation signal cannot be sent.
pub async fn cancel(&mut self) -> Result<(), ServiceError> {
self.cancel_with_reason(None).await
}
/// Cancel this subscription with a diagnostic reason.
///
/// # Errors
///
/// Returns a transport error when the cancellation signal cannot be sent.
pub async fn cancel_with_reason(&mut self, reason: Option<String>) -> Result<(), ServiceError> {
let Some(request) = self.request.take() else {
return Ok(());
};
request.cancel(reason).await?;
self.end = Some(SubscriptionEnd::Cancelled);
Ok(())
}
fn finish(&mut self, end: SubscriptionEnd) {
if let Some(request) = self.request.take() {
request.peer.unregister_subscription(&self.id);
}
self.end = Some(end);
}
async fn cancel_as_abrupt(&mut self, reason: &'static str) {
if let Some(request) = self.request.take() {
let _ = request.cancel(Some(reason.to_owned())).await;
}
self.end = Some(SubscriptionEnd::Abrupt);
}
fn accepts(&self, notification: &ServerNotification) -> bool {
match notification {
ServerNotification::ToolListChangedNotification(_) => {
self.acknowledged.tools_list_changed == Some(true)
}
ServerNotification::PromptListChangedNotification(_) => {
self.acknowledged.prompts_list_changed == Some(true)
}
ServerNotification::ResourceListChangedNotification(_) => {
self.acknowledged.resources_list_changed == Some(true)
}
ServerNotification::ResourceUpdatedNotification(update) => self
.acknowledged
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.contains(&update.params.uri)),
ServerNotification::SubscriptionsAcknowledgedNotification(_)
| ServerNotification::CancelledNotification(_)
| ServerNotification::ProgressNotification(_)
| ServerNotification::LoggingMessageNotification(_)
| ServerNotification::TaskStatusNotification(_)
| ServerNotification::CustomNotification(_) => false,
}
}
fn handle_response(
&mut self,
response: SubscriptionResponse,
) -> Result<Option<ServerNotification>, ServiceError> {
let response = match response {
Ok(response) => response,
Err(_) => {
self.finish(SubscriptionEnd::Abrupt);
return Ok(None);
}
};
let response = match response {
Ok(response) => response,
Err(ServiceError::TransportClosed) => {
self.finish(SubscriptionEnd::Abrupt);
return Ok(None);
}
Err(ServiceError::SubscriptionLagged { capacity }) => {
self.finish(SubscriptionEnd::Lagged { capacity });
return Ok(None);
}
Err(error) => {
self.finish(SubscriptionEnd::Abrupt);
return Err(error);
}
};
let ServerResult::SubscriptionsListenResult(result) = response else {
self.finish(SubscriptionEnd::Abrupt);
return Err(ServiceError::UnexpectedResponse);
};
if !result.result_type.is_complete()
|| result.meta.subscription_id().as_ref() != Some(&self.id)
{
self.finish(SubscriptionEnd::Abrupt);
return Err(ServiceError::UnexpectedResponse);
}
self.finish(SubscriptionEnd::Graceful(result));
Ok(None)
}
}
impl Drop for Subscription {
fn drop(&mut self) {
let Some(request) = self.request.take() else {
return;
};
request.peer.unregister_subscription(&self.id);
request.peer.try_cancel_request(
self.id.clone(),
Some("subscription handle dropped".to_owned()),
);
}
}
/// Selects how a client establishes its MCP lifecycle.
///
/// Existing [`ServiceExt::serve`] behavior remains legacy initialization.
@ -621,6 +910,106 @@ macro_rules! method {
}
impl Peer<RoleClient> {
/// Open a long-lived notification subscription and wait for its acknowledgment.
///
/// Notifications routed to the returned [`Subscription`] are not also
/// delivered through [`ClientHandler`](crate::ClientHandler) callbacks.
///
/// # Errors
///
/// Returns a service, transport, or protocol error when the request cannot
/// be established or the acknowledgment is invalid.
pub async fn listen(
&self,
notifications: SubscriptionFilter,
) -> Result<Subscription, ServiceError> {
self.listen_with_channel_capacity_inner(
notifications,
DEFAULT_SUBSCRIPTION_CHANNEL_CAPACITY,
)
.await
}
/// Open a subscription with an explicit notification buffer capacity.
///
/// Notifications routed to the returned [`Subscription`] are not also
/// delivered through [`ClientHandler`](crate::ClientHandler) callbacks.
///
/// # Errors
///
/// Returns a service, transport, or protocol error when the request cannot
/// be established or the acknowledgment is invalid.
pub async fn listen_with_capacity(
&self,
notifications: SubscriptionFilter,
channel_capacity: NonZeroUsize,
) -> Result<Subscription, ServiceError> {
self.listen_with_channel_capacity_inner(notifications, channel_capacity.get())
.await
}
async fn listen_with_channel_capacity_inner(
&self,
notifications: SubscriptionFilter,
channel_capacity: usize,
) -> Result<Subscription, ServiceError> {
let request = ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new(
SubscriptionsListenRequestParams::new(notifications.clone()),
));
let (handle, mut subscription_notifications) = self
.send_subscription_request(request, PeerRequestOptions::no_options(), channel_capacity)
.await?;
let id = handle.id.clone();
let mut pending = PendingSubscriptionRequest::new(handle);
tokio::select! {
biased;
notification = subscription_notifications.recv() => {
let Some(notification) = notification else {
pending.cancel("subscription stream closed before acknowledgment").await;
return Err(ServiceError::TransportClosed);
};
if notification.get_meta().subscription_id().as_ref() != Some(&id) {
pending.cancel("subscription acknowledgment ID mismatch").await;
return Err(ServiceError::UnexpectedResponse);
}
let ServerNotification::SubscriptionsAcknowledgedNotification(
acknowledgment,
) = notification else {
pending.cancel("notification received before subscription acknowledgment").await;
return Err(ServiceError::UnexpectedResponse);
};
let accepted = acknowledgment.params.notifications;
if !accepted.is_subset_of(&notifications) {
pending.cancel("subscription acknowledged an unrequested filter").await;
return Err(ServiceError::UnexpectedResponse);
}
let Some(handle) = pending.take() else {
return Err(ServiceError::TransportClosed);
};
Ok(Subscription {
id,
acknowledged: accepted,
notifications: subscription_notifications,
request: Some(handle),
end: None,
})
}
response = pending.recv() => {
pending.unregister(&id);
pending.disarm();
let Some(response) = response else {
return Err(ServiceError::TransportClosed);
};
match response {
Ok(Err(error)) => Err(error),
Ok(Ok(_)) => Err(ServiceError::UnexpectedResponse),
Err(_) => Err(ServiceError::TransportClosed),
}
}
}
}
/// Discover the server's supported protocol versions and capabilities.
///
/// The high-level client currently exposes this peer only after initialization;
@ -716,8 +1105,18 @@ impl Peer<RoleClient> {
method!(peer_req list_resources ListResourcesRequest(PaginatedRequestParams)? => ListResourcesResult);
method!(peer_req list_resource_templates ListResourceTemplatesRequest(PaginatedRequestParams)? => ListResourceTemplatesResult);
method!(peer_req read_resource ReadResourceRequest(ReadResourceRequestParams) => ReadResourceResult);
method!(peer_req subscribe SubscribeRequest(SubscribeRequestParams) );
method!(peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams));
method!(
#[deprecated(
note = "resources/subscribe is legacy-only; use Peer::listen for protocol version 2026-07-28"
)]
peer_req subscribe SubscribeRequest(SubscribeRequestParams)
);
method!(
#[deprecated(
note = "resources/unsubscribe is legacy-only; cancel the Subscription handle instead"
)]
peer_req unsubscribe UnsubscribeRequest(UnsubscribeRequestParams)
);
method!(peer_req call_tool CallToolRequest(CallToolRequestParams) => CallToolResult);
method!(peer_req list_tools ListToolsRequest(PaginatedRequestParams)? => ListToolsResult);

View file

@ -1,8 +1,8 @@
// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected.
#![expect(deprecated)]
use std::borrow::Cow;
#[cfg(feature = "elicitation")]
use std::collections::HashSet;
use std::{borrow::Cow, sync::Arc};
use thiserror::Error;
#[cfg(feature = "elicitation")]
@ -20,7 +20,8 @@ use crate::{
ProgressNotification, ProgressNotificationParam, PromptListChangedNotification,
ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification,
ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest,
ServerResult, ToolListChangedNotification,
ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification,
SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification,
},
transport::DynamicTransportError,
};
@ -41,6 +42,13 @@ impl ServiceRole for RoleServer {
type InitializeError = ServerInitializeError;
const IS_CLIENT: bool = false;
fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
match notification {
ClientNotification::CancelledNotification(notification) => Some(&notification.params),
_ => None,
}
}
}
/// It represents the error that may occur when serving the server.
@ -98,6 +106,284 @@ impl ServerInitializeError {
}
pub type ClientSink = Peer<RoleServer>;
/// Failure to send a notification through a [`SubscriptionSink`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SubscriptionSendError {
#[error("subscription is no longer active")]
SubscriptionClosed,
#[error("notification is not allowed on a subscription stream: {0}")]
UnsupportedNotification(&'static str),
#[error("notification was not accepted for this subscription: {0}")]
NotificationNotAccepted(&'static str),
#[error(transparent)]
Service(#[from] ServiceError),
}
/// A server-side notification sink scoped to one `subscriptions/listen` request.
///
/// The sink applies the accepted filter and adds the subscription request ID to
/// every notification it sends.
#[derive(Debug, Clone)]
pub struct SubscriptionSink {
peer: Peer<RoleServer>,
id: RequestId,
accepted: Arc<SubscriptionFilter>,
active: CancellationToken,
}
impl SubscriptionSink {
fn new(
peer: Peer<RoleServer>,
id: RequestId,
accepted: Arc<SubscriptionFilter>,
active: CancellationToken,
) -> Self {
Self {
peer,
id,
accepted,
active,
}
}
/// Return the JSON-RPC ID of the originating listen request.
pub fn id(&self) -> &RequestId {
&self.id
}
/// Return the filter accepted for this subscription.
pub fn accepted(&self) -> &SubscriptionFilter {
self.accepted.as_ref()
}
/// Send an allowed change notification with subscription metadata attached.
///
/// # Errors
///
/// Returns [`SubscriptionSendError::SubscriptionClosed`] after the request
/// ends, a filter error for disallowed notifications, or a transport error.
pub async fn send(
&self,
mut notification: ServerNotification,
) -> Result<(), SubscriptionSendError> {
if self.active.is_cancelled() {
return Err(SubscriptionSendError::SubscriptionClosed);
}
match &notification {
ServerNotification::ToolListChangedNotification(_) => {
if self.accepted.tools_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/tools/list_changed",
));
}
}
ServerNotification::PromptListChangedNotification(_) => {
if self.accepted.prompts_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/prompts/list_changed",
));
}
}
ServerNotification::ResourceListChangedNotification(_) => {
if self.accepted.resources_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/list_changed",
));
}
}
ServerNotification::ResourceUpdatedNotification(update) => {
let accepted = self
.accepted
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.contains(&update.params.uri));
if !accepted {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/updated",
));
}
}
ServerNotification::SubscriptionsAcknowledgedNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/subscriptions/acknowledged",
));
}
ServerNotification::CancelledNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/cancelled",
));
}
ServerNotification::ProgressNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/progress",
));
}
ServerNotification::LoggingMessageNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/message",
));
}
ServerNotification::TaskStatusNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/tasks/status",
));
}
ServerNotification::CustomNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"custom notification",
));
}
}
notification
.get_meta_mut()
.set_subscription_id(self.id.clone());
self.peer.send_notification(notification).await?;
Ok(())
}
/// Send `notifications/tools/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_tool_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ToolListChangedNotification(
ToolListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/prompts/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_prompt_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::PromptListChangedNotification(
PromptListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/resources/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_resource_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ResourceListChangedNotification(
ResourceListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/resources/updated` for an accepted URI.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_resource_updated(
&self,
uri: impl Into<String>,
) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ResourceUpdatedNotification(
ResourceUpdatedNotification::new(ResourceUpdatedNotificationParam::new(uri)),
))
.await
}
}
/// Context for one established server-side notification subscription.
///
/// The acknowledgment has already been sent before this context is handed to
/// [`ServerHandler::listen`](crate::ServerHandler::listen).
#[derive(Debug)]
pub struct SubscriptionContext {
request: RequestContext<RoleServer>,
requested: SubscriptionFilter,
accepted: Arc<SubscriptionFilter>,
sink: SubscriptionSink,
_active_guard: DropGuard,
}
impl SubscriptionContext {
pub(crate) async fn establish(
request: RequestContext<RoleServer>,
requested: SubscriptionFilter,
accepted: SubscriptionFilter,
) -> Result<Self, ErrorData> {
let active = request.ct.child_token();
let accepted = Arc::new(accepted);
let sink = SubscriptionSink::new(
request.peer.clone(),
request.id.clone(),
accepted.clone(),
active.clone(),
);
let mut acknowledgment = SubscriptionsAcknowledgedNotification::new(
SubscriptionsAcknowledgedNotificationParams::new(accepted.as_ref().clone()),
);
let mut meta = NotificationMetaObject::new();
meta.set_subscription_id(request.id.clone());
acknowledgment.extensions.insert(meta);
request
.peer
.send_notification(ServerNotification::SubscriptionsAcknowledgedNotification(
acknowledgment,
))
.await
.map_err(|error| {
ErrorData::internal_error(
format!("failed to acknowledge subscription: {error}"),
None,
)
})?;
Ok(Self {
request,
requested,
accepted,
sink,
_active_guard: active.drop_guard(),
})
}
/// Return the filter requested by the client.
pub fn requested(&self) -> &SubscriptionFilter {
&self.requested
}
/// Return the subset accepted by the server.
pub fn accepted(&self) -> &SubscriptionFilter {
self.accepted.as_ref()
}
/// Return a cloneable, filter-enforcing notification sink.
pub fn sink(&self) -> &SubscriptionSink {
&self.sink
}
/// Wait until the subscription request is cancelled.
pub async fn cancelled(&self) {
self.request.ct.cancelled().await;
}
/// Access the underlying request context.
pub fn request_context(&self) -> &RequestContext<RoleServer> {
&self.request
}
}
impl<S: Service<RoleServer>> ServiceExt<RoleServer> for S {
fn serve_with_ct<T, E, A>(
self,

View file

@ -6,7 +6,7 @@ use http::Response;
use http_body::Body;
use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody};
use sse_stream::{KeepAlive, Sse, SseBody};
use tokio_util::sync::CancellationToken;
use tokio_util::sync::{CancellationToken, DropGuard};
use super::http_header::EVENT_STREAM_MIME_TYPE;
use crate::model::{ClientJsonRpcMessage, ServerJsonRpcMessage};
@ -57,6 +57,25 @@ impl sse_stream::Timer for TokioTimer {
}
}
pin_project_lite::pin_project! {
struct CancelOnDropStream<S> {
#[pin]
inner: S,
_drop_guard: DropGuard,
}
}
impl<S: futures::Stream> futures::Stream for CancelOnDropStream<S> {
type Item = S::Item;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.project().inner.poll_next(cx)
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ServerSseMessage {
@ -108,6 +127,7 @@ pub(crate) fn sse_stream_response(
ct: CancellationToken,
) -> Response<BoxBody<Bytes, Infallible>> {
use futures::StreamExt;
let cancelled = ct.clone();
let stream = stream
.map(|message| {
let mut sse = if let Some(ref msg) = message.message {
@ -126,7 +146,11 @@ pub(crate) fn sse_stream_response(
Result::<Sse, Infallible>::Ok(sse)
})
.take_until(async move { ct.cancelled().await });
.take_until(async move { cancelled.cancelled().await });
let stream = CancelOnDropStream {
inner: stream,
_drop_guard: ct.drop_guard(),
};
let stream = SseBody::new(stream);
let stream = match keep_alive {
@ -140,6 +164,7 @@ pub(crate) fn sse_stream_response(
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, EVENT_STREAM_MIME_TYPE)
.header(http::header::CACHE_CONTROL, "no-cache")
.header("X-Accel-Buffering", "no")
.body(stream)
.expect("valid response")
}

View file

@ -30,6 +30,7 @@ use crate::{
};
type BoxedSseStream = BoxStream<'static, Result<Sse, SseError>>;
type SseTaskResult<E> = (Option<RequestId>, Result<(), StreamableHttpError<E>>);
const SESSION_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
fn build_request_headers(
@ -683,7 +684,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
}
fn spawn_common_stream(
streams: &mut tokio::task::JoinSet<Result<(), StreamableHttpError<C::Error>>>,
streams: &mut tokio::task::JoinSet<SseTaskResult<C::Error>>,
client: C,
session_id: Arc<str>,
config: &StreamableHttpClientTransportConfig,
@ -699,7 +700,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
let max_sse_event_size = config.max_sse_event_size;
streams.spawn(async move {
match client
let result = match client
.get_stream_with_max_sse_event_size(
uri,
session_id.clone(),
@ -739,7 +740,8 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
tracing::error!("fail to get common stream: {error}");
Err(error)
}
}
};
(None, result)
});
}
@ -887,7 +889,10 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
));
}
};
let mut session_id: Option<Arc<str>> = if let Some(session_id) = session_id {
let mut uses_modern_http = !is_legacy_startup;
let mut session_id: Option<Arc<str>> = if uses_modern_http {
None
} else if let Some(session_id) = session_id {
Some(session_id.into())
} else {
if !self.config.allow_stateless {
@ -951,10 +956,14 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
enum Event<W: Worker, E: std::error::Error + Send + Sync + 'static> {
ClientMessage(WorkerSendRequest<W>),
ServerMessage(ServerJsonRpcMessage),
StreamResult(Result<(), StreamableHttpError<E>>),
StreamResult {
request_id: Option<RequestId>,
result: Result<(), StreamableHttpError<E>>,
},
}
let mut streams = tokio::task::JoinSet::new();
let mut pending_stream_response_ids = HashSet::new();
let mut request_stream_cancellations = HashMap::<RequestId, CancellationToken>::new();
let mut awaiting_fallback_initialized = false;
if let Some(session_id) = &session_id {
Self::spawn_common_stream(
@ -990,7 +999,15 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
terminated_stream = streams.join_next(), if !streams.is_empty() => {
match terminated_stream {
Some(result) => {
Event::StreamResult(result.map_err(StreamableHttpError::TokioJoinError).and_then(std::convert::identity))
match result {
Ok((request_id, result)) => {
Event::StreamResult { request_id, result }
}
Err(error) => Event::StreamResult {
request_id: None,
result: Err(StreamableHttpError::TokioJoinError(error)),
},
}
}
None => {
continue
@ -1001,6 +1018,25 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
match event {
Event::ClientMessage(send_request) => {
let WorkerSendRequest { message, responder } = send_request;
let cancellation_request_id = match &message {
ClientJsonRpcMessage::Notification(notification) => {
match &notification.notification {
ClientNotification::CancelledNotification(cancelled) => {
cancelled.params.request_id.clone()
}
_ => None,
}
}
_ => None,
};
if uses_modern_http && let Some(request_id) = cancellation_request_id {
if let Some(stream_ct) = request_stream_cancellations.remove(&request_id) {
stream_ct.cancel();
}
pending_stream_response_ids.remove(&request_id);
let _ = responder.send(Ok(()));
continue;
}
let is_fallback_initialize = saved_init_request.is_none()
&& matches!(
&message,
@ -1021,6 +1057,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
&& streams.is_empty(),
"discover bootstrap must not create session state"
);
uses_modern_http = false;
let response = self
.client
@ -1228,6 +1265,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
Ok(())
}
Ok(StreamableHttpPostResponse::Sse(stream, ..)) => {
let stream_request_id = request_id.clone();
Self::mark_stream_response_pending(
&mut pending_stream_response_ids,
request_id,
@ -1242,12 +1280,24 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
config.max_sse_event_size,
self.config.retry_config.clone(),
);
streams.spawn(Self::execute_sse_stream(
sse_stream,
sse_worker_tx.clone(),
true,
transport_task_ct.child_token(),
));
let stream_ct = transport_task_ct.child_token();
if uses_modern_http
&& let Some(request_id) =
stream_request_id.as_ref()
{
request_stream_cancellations.insert(
request_id.clone(),
stream_ct.clone(),
);
}
let stream_tx = sse_worker_tx.clone();
streams.spawn(async move {
let result = Self::execute_sse_stream(
sse_stream, stream_tx, true, stream_ct,
)
.await;
(stream_request_id, result)
});
tracing::trace!("got new sse stream after re-init");
Ok(())
}
@ -1278,6 +1328,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
Ok(())
}
Ok(StreamableHttpPostResponse::Sse(stream, ..)) => {
let stream_request_id = request_id.clone();
Self::mark_stream_response_pending(
&mut pending_stream_response_ids,
request_id,
@ -1292,12 +1343,20 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
config.max_sse_event_size,
self.config.retry_config.clone(),
);
streams.spawn(Self::execute_sse_stream(
sse_stream,
sse_worker_tx.clone(),
true,
transport_task_ct.child_token(),
));
let stream_ct = transport_task_ct.child_token();
if uses_modern_http && let Some(request_id) = stream_request_id.as_ref()
{
request_stream_cancellations
.insert(request_id.clone(), stream_ct.clone());
}
let stream_tx = sse_worker_tx.clone();
streams.spawn(async move {
let result = Self::execute_sse_stream(
sse_stream, stream_tx, true, stream_ct,
)
.await;
(stream_request_id, result)
});
tracing::trace!("got new sse stream");
Ok(())
}
@ -1322,6 +1381,11 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
let _ = responder.send(send_result);
}
Event::ServerMessage(mut json_rpc_message) => {
if let Some(response_id) = Self::server_response_id(&json_rpc_message)
&& let Some(stream_ct) = request_stream_cancellations.remove(response_id)
{
stream_ct.cancel();
}
Self::clear_stream_response_pending(
&mut pending_stream_response_ids,
&json_rpc_message,
@ -1336,7 +1400,26 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
break 'main_loop Err(e);
}
}
Event::StreamResult(result) => {
Event::StreamResult { request_id, result } => {
if let Some(request_id) = request_id {
Self::drain_queued_stream_messages(
&mut sse_worker_rx,
&mut context,
&mut pending_stream_response_ids,
)
.await?;
request_stream_cancellations.remove(&request_id);
if pending_stream_response_ids.remove(&request_id) {
context
.send_to_handler(ServerJsonRpcMessage::error(
ErrorData::transport_closed(
"streamable HTTP response stream closed before its final response",
),
Some(request_id),
))
.await?;
}
}
if result.is_err() {
tracing::warn!(
"sse client event stream terminated with error: {:?}",

View file

@ -30,7 +30,7 @@ use crate::{
ProtocolVersion, RequestId, ServerJsonRpcMessage,
},
serve_server,
service::serve_directly_with_ct,
service::{serve_directly_with_ct, uses_legacy_lifecycle},
transport::{
OneshotTransport, TransportAdapterIdentity,
common::{
@ -259,8 +259,9 @@ fn message_has_per_request_protocol_version(message: &ClientJsonRpcMessage) -> b
clippy::result_large_err,
reason = "BoxResponse is intentionally large; matches other handlers in this file"
)]
// SEP-2567: sessions are removed from 2026-07-28; older versions are legacy.
// Validates protocol-version consistency and returns `Ok(true)` only for a valid legacy request.
// SEP-2567: sessions are removed from the discover lifecycle. Validate
// protocol-version consistency, then classify the request with the shared
// lifecycle helper.
fn is_legacy_request(
message: Option<&ClientJsonRpcMessage>,
headers: &HeaderMap,
@ -280,6 +281,17 @@ fn is_legacy_request(
validate_request_protocol_version_meta(headers, message)?;
}
let uses_discover_lifecycle = matches!(
message,
Some(ClientJsonRpcMessage::Request(req))
if !matches!(&req.request, ClientRequest::InitializeRequest(_))
&& req
.request
.get_meta()
.missing_required_keys(&ProtocolVersion::V_2026_07_28)
.is_empty()
);
let from_body = match message {
Some(ClientJsonRpcMessage::Request(req)) => match &req.request {
ClientRequest::InitializeRequest(init) => Some(init.params.protocol_version.clone()),
@ -295,7 +307,10 @@ fn is_legacy_request(
.and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok())
})
.unwrap_or(ProtocolVersion::V_2025_03_26);
Ok(version < ProtocolVersion::V_2026_07_28)
Ok(uses_legacy_lifecycle(
Some(&version),
uses_discover_lifecycle,
))
}
fn method_not_allowed_response() -> BoxResponse {