use crate::types::{RequestId, RequestParamsMeta, Uri};
use serde::{Deserialize, Serialize};
#[cfg(feature = "server")]
use crate::app::handler::{FromHandlerParams, HandlerParams};
#[cfg(feature = "server")]
use crate::error::Error;
#[cfg(feature = "server")]
use crate::types::{IntoResponse, Request, Response, request::FromRequest};
pub const SUBSCRIPTION_ID_KEY: &str = "io.modelcontextprotocol/subscriptionId";
pub mod commands {
pub const LISTEN: &str = "subscriptions/listen";
pub const ACKNOWLEDGED: &str = "notifications/subscriptions/acknowledged";
}
#[inline]
fn is_false(flag: &bool) -> bool {
!*flag
}
#[inline]
pub(crate) fn is_subscribable(method: &str) -> bool {
use crate::types::{prompt, resource, tool};
matches!(
method,
tool::commands::LIST_CHANGED
| prompt::commands::LIST_CHANGED
| resource::commands::LIST_CHANGED
| resource::commands::UPDATED
)
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionFilter {
#[serde(default, skip_serializing_if = "is_false")]
pub tools_list_changed: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub prompts_list_changed: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub resources_list_changed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resource_subscriptions: Vec<Uri>,
}
impl SubscriptionFilter {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn with_tools_changed(mut self) -> Self {
self.tools_list_changed = true;
self
}
#[inline]
pub fn with_prompts_changed(mut self) -> Self {
self.prompts_list_changed = true;
self
}
#[inline]
pub fn with_resources_changed(mut self) -> Self {
self.resources_list_changed = true;
self
}
#[inline]
pub fn with_resource(mut self, uri: impl Into<Uri>) -> Self {
let uri = uri.into();
if !self.resource_subscriptions.contains(&uri) {
self.resource_subscriptions.push(uri);
}
self
}
#[inline]
pub fn with_resources<U: Into<Uri>>(mut self, uris: impl IntoIterator<Item = U>) -> Self {
for uri in uris {
self = self.with_resource(uri);
}
self
}
#[inline]
pub fn is_empty(&self) -> bool {
!self.tools_list_changed
&& !self.prompts_list_changed
&& !self.resources_list_changed
&& self.resource_subscriptions.is_empty()
}
pub fn intersection(&self, other: &Self) -> Self {
Self {
tools_list_changed: self.tools_list_changed && other.tools_list_changed,
prompts_list_changed: self.prompts_list_changed && other.prompts_list_changed,
resources_list_changed: self.resources_list_changed && other.resources_list_changed,
resource_subscriptions: self
.resource_subscriptions
.iter()
.filter(|uri| other.resource_subscriptions.contains(uri))
.cloned()
.collect(),
}
}
pub fn is_subset_of(&self, other: &Self) -> bool {
(!self.tools_list_changed || other.tools_list_changed)
&& (!self.prompts_list_changed || other.prompts_list_changed)
&& (!self.resources_list_changed || other.resources_list_changed)
&& self
.resource_subscriptions
.iter()
.all(|uri| other.resource_subscriptions.contains(uri))
}
pub fn supported_by(&self, capabilities: &crate::types::ServerCapabilities) -> Self {
let resources_subscribe = capabilities
.resources
.as_ref()
.is_some_and(|res| res.subscribe);
Self {
tools_list_changed: self.tools_list_changed
&& capabilities
.tools
.as_ref()
.is_some_and(|tools| tools.list_changed),
prompts_list_changed: self.prompts_list_changed
&& capabilities
.prompts
.as_ref()
.is_some_and(|prompts| prompts.list_changed),
resources_list_changed: self.resources_list_changed
&& capabilities
.resources
.as_ref()
.is_some_and(|res| res.list_changed),
resource_subscriptions: if resources_subscribe {
self.resource_subscriptions.clone()
} else {
Vec::new()
},
}
}
pub fn matches(&self, method: &str, uri: Option<&Uri>) -> bool {
use crate::types::{prompt, resource, tool};
match method {
tool::commands::LIST_CHANGED => self.tools_list_changed,
prompt::commands::LIST_CHANGED => self.prompts_list_changed,
resource::commands::LIST_CHANGED => self.resources_list_changed,
resource::commands::UPDATED => {
uri.is_some_and(|uri| self.resource_subscriptions.contains(uri))
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionMeta {
#[serde(rename = "io.modelcontextprotocol/subscriptionId")]
pub subscription_id: RequestId,
}
impl SubscriptionMeta {
#[inline]
pub fn new(subscription_id: RequestId) -> Self {
Self { subscription_id }
}
}
impl From<RequestId> for SubscriptionMeta {
#[inline]
fn from(subscription_id: RequestId) -> Self {
Self::new(subscription_id)
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct SubscriptionsListenRequestParams {
pub notifications: SubscriptionFilter,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<RequestParamsMeta>,
}
impl SubscriptionsListenRequestParams {
#[inline]
pub fn new(notifications: SubscriptionFilter) -> Self {
Self {
notifications,
meta: None,
}
}
}
impl From<SubscriptionFilter> for SubscriptionsListenRequestParams {
#[inline]
fn from(notifications: SubscriptionFilter) -> Self {
Self::new(notifications)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionsAcknowledgedNotificationParams {
pub notifications: SubscriptionFilter,
#[serde(rename = "_meta")]
pub meta: SubscriptionMeta,
}
impl SubscriptionsAcknowledgedNotificationParams {
#[inline]
pub fn new(subscription_id: RequestId, notifications: SubscriptionFilter) -> Self {
Self {
notifications,
meta: SubscriptionMeta::new(subscription_id),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionsListenResult {
#[serde(rename = "_meta")]
pub meta: SubscriptionMeta,
}
impl SubscriptionsListenResult {
#[inline]
pub fn new(subscription_id: RequestId) -> Self {
Self {
meta: SubscriptionMeta::new(subscription_id),
}
}
}
#[cfg(feature = "server")]
impl FromHandlerParams for SubscriptionsListenRequestParams {
#[inline]
fn from_params(params: &HandlerParams) -> Result<Self, Error> {
let req = Request::from_params(params)?;
Self::from_request(req)
}
}
#[cfg(feature = "server")]
impl IntoResponse for SubscriptionsListenResult {
#[inline]
fn into_response(self, req_id: RequestId) -> Response {
match serde_json::to_value(self) {
Ok(v) => Response::success(req_id, v),
Err(err) => Response::error(req_id, err.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
PromptsCapability, ResourcesCapability, ServerCapabilities, ToolsCapability,
};
fn caps(
tools: bool,
prompts: bool,
resources_list: bool,
subscribe: bool,
) -> ServerCapabilities {
ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: tools,
}),
prompts: Some(PromptsCapability {
list_changed: prompts,
}),
resources: Some(ResourcesCapability {
list_changed: resources_list,
subscribe,
}),
..Default::default()
}
}
#[test]
fn it_omits_unset_categories_on_the_wire() {
let filter = SubscriptionFilter::new().with_tools_changed();
let json = serde_json::to_value(&filter).unwrap();
assert_eq!(json["toolsListChanged"], serde_json::json!(true));
assert!(json.get("promptsListChanged").is_none());
assert!(json.get("resourcesListChanged").is_none());
assert!(json.get("resourceSubscriptions").is_none());
}
#[test]
fn it_roundtrips_filter() {
let filter = SubscriptionFilter::new()
.with_tools_changed()
.with_prompts_changed()
.with_resources_changed()
.with_resources(["res://a", "res://b"]);
let json = serde_json::to_string(&filter).unwrap();
let back: SubscriptionFilter = serde_json::from_str(&json).unwrap();
assert_eq!(filter, back);
}
#[test]
fn it_deserializes_absent_fields_as_unsubscribed() {
let filter: SubscriptionFilter = serde_json::from_str("{}").unwrap();
assert!(filter.is_empty());
}
#[test]
fn it_deduplicates_resource_uris() {
let filter = SubscriptionFilter::new().with_resources(["res://a", "res://a"]);
assert_eq!(filter.resource_subscriptions.len(), 1);
}
#[test]
fn it_intersects_filters() {
let requested = SubscriptionFilter::new()
.with_tools_changed()
.with_prompts_changed()
.with_resources(["res://a", "res://b"]);
let offered = SubscriptionFilter::new()
.with_prompts_changed()
.with_resources_changed()
.with_resources(["res://b", "res://c"]);
let accepted = requested.intersection(&offered);
assert!(!accepted.tools_list_changed);
assert!(accepted.prompts_list_changed);
assert!(!accepted.resources_list_changed);
assert_eq!(accepted.resource_subscriptions, [Uri::from("res://b")]);
}
#[test]
fn it_checks_subset() {
let requested = SubscriptionFilter::new()
.with_tools_changed()
.with_resource("res://a");
assert!(requested.is_subset_of(&requested));
assert!(
SubscriptionFilter::new()
.with_tools_changed()
.is_subset_of(&requested)
);
assert!(SubscriptionFilter::new().is_subset_of(&requested));
assert!(
!SubscriptionFilter::new()
.with_prompts_changed()
.is_subset_of(&requested)
);
assert!(
!SubscriptionFilter::new()
.with_resource("res://b")
.is_subset_of(&requested)
);
}
#[test]
fn it_narrows_to_advertised_capabilities() {
let requested = SubscriptionFilter::new()
.with_tools_changed()
.with_prompts_changed()
.with_resources_changed()
.with_resource("res://a");
let accepted = requested.supported_by(&caps(true, false, true, false));
assert!(accepted.tools_list_changed);
assert!(!accepted.prompts_list_changed);
assert!(accepted.resources_list_changed);
assert!(accepted.resource_subscriptions.is_empty());
}
#[test]
fn it_narrows_to_nothing_without_capabilities() {
let requested = SubscriptionFilter::new()
.with_tools_changed()
.with_resource("res://a");
let accepted = requested.supported_by(&ServerCapabilities::default());
assert!(accepted.is_empty());
}
#[test]
fn it_keeps_resource_subscriptions_when_subscribe_is_advertised() {
let requested = SubscriptionFilter::new().with_resource("res://a");
let accepted = requested.supported_by(&caps(false, false, false, true));
assert_eq!(accepted.resource_subscriptions, [Uri::from("res://a")]);
}
#[test]
fn it_matches_only_subscribed_methods() {
use crate::types::{prompt, resource, tool};
let filter = SubscriptionFilter::new()
.with_tools_changed()
.with_resource("res://a");
assert!(filter.matches(tool::commands::LIST_CHANGED, None));
assert!(!filter.matches(prompt::commands::LIST_CHANGED, None));
assert!(!filter.matches(resource::commands::LIST_CHANGED, None));
assert!(filter.matches(resource::commands::UPDATED, Some(&Uri::from("res://a"))));
assert!(!filter.matches(resource::commands::UPDATED, Some(&Uri::from("res://b"))));
assert!(!filter.matches(resource::commands::UPDATED, None));
assert!(!filter.matches("notifications/progress", None));
}
#[test]
fn it_serializes_subscription_id_meta() {
let params = SubscriptionsAcknowledgedNotificationParams::new(
RequestId::Number(1),
SubscriptionFilter::new().with_tools_changed(),
);
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["_meta"][SUBSCRIPTION_ID_KEY], serde_json::json!(1));
assert_eq!(json["notifications"]["toolsListChanged"], true);
}
#[test]
fn it_serializes_graceful_close_result() {
let result = SubscriptionsListenResult::new(RequestId::String("sub-1".into()));
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["_meta"][SUBSCRIPTION_ID_KEY], "sub-1");
}
#[test]
fn it_parses_listen_params() {
let json = r#"{"notifications":{"toolsListChanged":true,
"resourceSubscriptions":["file:///project/config.json"]}}"#;
let params: SubscriptionsListenRequestParams = serde_json::from_str(json).unwrap();
assert!(params.notifications.tools_list_changed);
assert_eq!(
params.notifications.resource_subscriptions,
[Uri::from("file:///project/config.json")]
);
}
}