use crate::types::capabilities::ServerCapabilities;
use crate::types::jsonrpc::RequestId;
use crate::types::mrtr::META_KEY;
use crate::types::notifications::ServerNotification;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const SUBSCRIPTIONS_LISTEN_METHOD: &str = "subscriptions/listen";
pub const ACKNOWLEDGED_METHOD: &str = "notifications/subscriptions/acknowledged";
pub const SUBSCRIPTION_ID_META_KEY: &str = "io.modelcontextprotocol/subscriptionId";
pub const MAX_AGREED_RESOURCE_SUBSCRIPTIONS: usize = 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub tools_list_changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompts_list_changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources_list_changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_subscriptions: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SubscriptionNotificationKind {
ToolsListChanged,
PromptsListChanged,
ResourcesListChanged,
ResourceUpdated(String),
}
pub(crate) fn subscription_kind_of(
notification: &ServerNotification,
) -> Option<SubscriptionNotificationKind> {
match notification {
ServerNotification::ToolsChanged => Some(SubscriptionNotificationKind::ToolsListChanged),
ServerNotification::PromptsChanged => {
Some(SubscriptionNotificationKind::PromptsListChanged)
},
ServerNotification::ResourcesChanged => {
Some(SubscriptionNotificationKind::ResourcesListChanged)
},
ServerNotification::ResourceUpdated(params) => Some(
SubscriptionNotificationKind::ResourceUpdated(params.uri.clone()),
),
ServerNotification::Progress(_)
| ServerNotification::LogMessage(_)
| ServerNotification::RootsListChanged
| ServerNotification::TaskStatus(_) => None,
}
}
impl SubscriptionFilter {
pub fn is_empty(&self) -> bool {
!self.tools_list_changed.unwrap_or(false)
&& !self.prompts_list_changed.unwrap_or(false)
&& !self.resources_list_changed.unwrap_or(false)
&& self
.resource_subscriptions
.as_ref()
.is_none_or(|uris| uris.is_empty())
}
#[must_use]
pub fn intersect_with_capabilities(&self, capabilities: &ServerCapabilities) -> Self {
let [tools, prompts, resources_list, resource_subscribe] = supported_flags(capabilities);
Self {
tools_list_changed: agreed_flag(self.tools_list_changed, tools),
prompts_list_changed: agreed_flag(self.prompts_list_changed, prompts),
resources_list_changed: agreed_flag(self.resources_list_changed, resources_list),
resource_subscriptions: match (&self.resource_subscriptions, resource_subscribe) {
(Some(uris), true) if !uris.is_empty() => {
if uris.len() > MAX_AGREED_RESOURCE_SUBSCRIPTIONS {
tracing::warn!(
target: "mcp.subscriptions",
requested = uris.len(),
max = MAX_AGREED_RESOURCE_SUBSCRIPTIONS,
"truncated a subscriptions/listen resourceSubscriptions list to the \
per-stream bound; the acknowledgement reports the agreed subset"
);
}
Some(
uris.iter()
.take(MAX_AGREED_RESOURCE_SUBSCRIPTIONS)
.cloned()
.collect(),
)
},
_ => None,
},
}
}
#[must_use]
pub(crate) fn covers(&self, kind: &SubscriptionNotificationKind) -> bool {
match kind {
SubscriptionNotificationKind::ToolsListChanged => {
self.tools_list_changed.unwrap_or(false)
},
SubscriptionNotificationKind::PromptsListChanged => {
self.prompts_list_changed.unwrap_or(false)
},
SubscriptionNotificationKind::ResourcesListChanged => {
self.resources_list_changed.unwrap_or(false)
},
SubscriptionNotificationKind::ResourceUpdated(uri) => self
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.iter().any(|candidate| candidate == uri)),
}
}
}
fn agreed_flag(requested: Option<bool>, supported: bool) -> Option<bool> {
(requested.unwrap_or(false) && supported).then_some(true)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SubscriptionsListenParams {
pub notifications: SubscriptionFilter,
#[serde(
rename = "_meta",
alias = "meta",
default,
skip_serializing_if = "Option::is_none"
)]
pub meta: Option<Value>,
}
impl SubscriptionsListenParams {
#[must_use]
pub fn new(notifications: SubscriptionFilter) -> Self {
Self {
notifications,
meta: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SubscriptionAcknowledgedParams {
pub notifications: SubscriptionFilter,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Value>,
}
impl SubscriptionAcknowledgedParams {
#[must_use]
pub fn new(notifications: SubscriptionFilter, subscription_id: &RequestId) -> Self {
Self {
notifications,
meta: Some(subscription_id_meta(subscription_id)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SubscriptionsListenResult {
#[serde(rename = "_meta")]
pub meta: serde_json::Map<String, Value>,
}
impl SubscriptionsListenResult {
#[must_use]
pub fn new(subscription_id: &RequestId) -> Self {
Self {
meta: subscription_id_map(subscription_id),
}
}
#[must_use]
pub fn subscription_id(&self) -> Option<&Value> {
self.meta.get(SUBSCRIPTION_ID_META_KEY)
}
}
fn subscription_id_map(subscription_id: &RequestId) -> serde_json::Map<String, Value> {
let mut meta = serde_json::Map::new();
meta.insert(
SUBSCRIPTION_ID_META_KEY.to_string(),
request_id_value(subscription_id),
);
meta
}
#[must_use]
pub fn subscription_id_meta(subscription_id: &RequestId) -> Value {
Value::Object(subscription_id_map(subscription_id))
}
pub(crate) fn request_id_value(id: &RequestId) -> Value {
serde_json::json!(id)
}
pub(crate) fn tag_notification_with_subscription_id(
frame: &mut Value,
subscription_id: &RequestId,
) {
let Some(object) = frame.as_object_mut() else {
return;
};
if !matches!(object.get("params"), Some(Value::Object(_))) {
object.insert("params".to_string(), Value::Object(serde_json::Map::new()));
}
let Some(params) = object.get_mut("params").and_then(Value::as_object_mut) else {
return;
};
if !matches!(params.get(META_KEY), Some(Value::Object(_))) {
params.insert(META_KEY.to_string(), Value::Object(serde_json::Map::new()));
}
if let Some(meta) = params.get_mut(META_KEY).and_then(Value::as_object_mut) {
meta.extend(subscription_id_map(subscription_id));
}
}
#[must_use]
pub fn advertises_subscriptions(capabilities: &ServerCapabilities) -> bool {
supported_flags(capabilities).iter().any(|flag| *flag)
}
fn supported_flags(capabilities: &ServerCapabilities) -> [bool; 4] {
[
capabilities
.tools
.as_ref()
.and_then(|c| c.list_changed)
.unwrap_or(false),
capabilities
.prompts
.as_ref()
.and_then(|c| c.list_changed)
.unwrap_or(false),
capabilities
.resources
.as_ref()
.and_then(|c| c.list_changed)
.unwrap_or(false),
capabilities
.resources
.as_ref()
.and_then(|c| c.subscribe)
.unwrap_or(false),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::capabilities::{PromptCapabilities, ResourceCapabilities, ToolCapabilities};
use crate::types::notifications::{LogMessageParams, LoggingLevel, ResourceUpdatedParams};
use serde_json::json;
const TOOLS: usize = 0;
const PROMPTS: usize = 1;
const RESOURCES_LIST: usize = 2;
const RESOURCES_SUB: usize = 3;
fn caps(flags: [bool; 4]) -> ServerCapabilities {
let resources =
(flags[RESOURCES_LIST] || flags[RESOURCES_SUB]).then(|| ResourceCapabilities {
subscribe: flags[RESOURCES_SUB].then_some(true),
list_changed: flags[RESOURCES_LIST].then_some(true),
});
ServerCapabilities {
tools: flags[TOOLS].then_some(ToolCapabilities {
list_changed: Some(true),
}),
prompts: flags[PROMPTS].then_some(PromptCapabilities {
list_changed: Some(true),
}),
resources,
..ServerCapabilities::default()
}
}
#[test]
fn filter_round_trips_the_literal_camel_case_spellings() {
let filter = SubscriptionFilter {
tools_list_changed: Some(true),
prompts_list_changed: Some(true),
resources_list_changed: Some(true),
resource_subscriptions: Some(vec!["mem://a".to_string()]),
};
let wire = serde_json::to_value(&filter).expect("serializes");
assert_eq!(
wire,
json!({
"toolsListChanged": true,
"promptsListChanged": true,
"resourcesListChanged": true,
"resourceSubscriptions": ["mem://a"],
}),
"the four wire keys are camelCase and spelled exactly as the schema declares"
);
let back: SubscriptionFilter = serde_json::from_value(wire).expect("round-trips");
assert_eq!(back, filter);
}
#[test]
fn an_absent_field_is_omitted_from_the_wire() {
let wire = serde_json::to_value(SubscriptionFilter::default()).expect("serializes");
assert_eq!(wire, json!({}), "no `null`s on the wire");
}
#[test]
fn filter_matches_the_shape_recorded_in_the_spec_recheck() {
let recorded = json!({
"toolsListChanged": true,
"promptsListChanged": false,
"resourcesListChanged": true,
"resourceSubscriptions": ["file:///a.txt", "mem://b"],
});
let filter: SubscriptionFilter =
serde_json::from_value(recorded).expect("the recorded example deserializes");
assert_eq!(filter.tools_list_changed, Some(true));
assert_eq!(filter.prompts_list_changed, Some(false));
assert_eq!(filter.resources_list_changed, Some(true));
assert_eq!(
filter.resource_subscriptions.as_deref(),
Some(&["file:///a.txt".to_string(), "mem://b".to_string()][..]),
"resourceSubscriptions is `string[]`, not a bool and not a map"
);
}
#[test]
fn a_boolean_resource_subscriptions_is_rejected() {
let wrong = json!({ "resourceSubscriptions": true });
assert!(
serde_json::from_value::<SubscriptionFilter>(wrong).is_err(),
"a boolean resourceSubscriptions must NOT deserialize"
);
}
#[test]
fn listen_params_require_the_notifications_field() {
let params: SubscriptionsListenParams =
serde_json::from_value(json!({ "notifications": { "toolsListChanged": true } }))
.expect("deserializes");
assert_eq!(params.notifications.tools_list_changed, Some(true));
assert!(
serde_json::from_value::<SubscriptionsListenParams>(json!({})).is_err(),
"`notifications` is REQUIRED (no `?` in the schema declaration)"
);
}
#[test]
fn listen_params_read_both_meta_spellings() {
for key in ["_meta", "meta"] {
let params: SubscriptionsListenParams = serde_json::from_value(json!({
"notifications": {},
key: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
}))
.expect("deserializes");
assert!(params.meta.is_some(), "`{key}` reaches the meta field");
}
let out = serde_json::to_value(SubscriptionsListenParams {
notifications: SubscriptionFilter::default(),
meta: Some(json!({ "k": 1 })),
})
.expect("serializes");
assert!(
out.get("_meta").is_some() && out.get("meta").is_none(),
"egress uses the spec spelling only"
);
}
#[test]
fn acknowledged_params_carry_the_subscription_id() {
let ack = SubscriptionAcknowledgedParams::new(
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
&RequestId::Number(1),
);
let wire = serde_json::to_value(&ack).expect("serializes");
assert_eq!(
wire,
json!({
"notifications": { "toolsListChanged": true },
"_meta": { SUBSCRIPTION_ID_META_KEY: 1 },
})
);
}
#[test]
fn listen_result_is_empty_apart_from_the_required_meta() {
let result = SubscriptionsListenResult::new(&RequestId::String("abc".to_string()));
let wire = serde_json::to_value(&result).expect("serializes");
assert_eq!(
wire,
json!({ "_meta": { SUBSCRIPTION_ID_META_KEY: "abc" } })
);
assert_eq!(result.subscription_id(), Some(&json!("abc")));
}
#[test]
fn listen_result_meta_keeps_the_envelope_keys() {
let wire = json!({
"_meta": {
SUBSCRIPTION_ID_META_KEY: 7,
"io.modelcontextprotocol/serverInfo": { "name": "s", "version": "1" },
},
});
let result: SubscriptionsListenResult =
serde_json::from_value(wire.clone()).expect("deserializes");
assert_eq!(serde_json::to_value(&result).expect("re-serializes"), wire);
}
#[test]
fn every_frame_is_tagged_with_the_subscription_id() {
let mut frame = json!({
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
});
tag_notification_with_subscription_id(&mut frame, &RequestId::Number(1));
assert_eq!(frame["params"]["_meta"][SUBSCRIPTION_ID_META_KEY], json!(1));
let mut frame = json!({
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": { "uri": "mem://a", "_meta": { "keep": true } },
});
tag_notification_with_subscription_id(&mut frame, &RequestId::String("s".to_string()));
assert_eq!(frame["params"]["uri"], json!("mem://a"));
assert_eq!(frame["params"]["_meta"]["keep"], json!(true));
assert_eq!(
frame["params"]["_meta"][SUBSCRIPTION_ID_META_KEY],
json!("s")
);
}
#[test]
fn advertises_subscriptions_over_all_sixteen_capability_combinations() {
for bits in 0u8..16 {
let flags = [bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0];
let (tools, prompts, resources_list, resources_sub) = (
flags[TOOLS],
flags[PROMPTS],
flags[RESOURCES_LIST],
flags[RESOURCES_SUB],
);
let expected = flags.iter().any(|f| *f);
assert_eq!(
advertises_subscriptions(&caps(flags)),
expected,
"bits={bits:04b} (tools={tools}, prompts={prompts}, \
resourcesListChanged={resources_list}, resourcesSubscribe={resources_sub})"
);
}
}
#[test]
fn a_false_capability_is_not_an_advertisement() {
let capabilities = ServerCapabilities {
tools: Some(ToolCapabilities {
list_changed: Some(false),
}),
resources: Some(ResourceCapabilities {
subscribe: Some(false),
list_changed: Some(false),
}),
..ServerCapabilities::default()
};
assert!(
!advertises_subscriptions(&capabilities),
"`listChanged: false` is falsy in the conformance expression too"
);
}
proptest::proptest! {
#[test]
fn the_agreed_filter_is_the_intersection_and_nothing_more(
requested_flags in proptest::prelude::any::<[bool; 4]>(),
supported_flags in proptest::prelude::any::<[bool; 4]>(),
) {
let uri = "mem://a".to_string();
let requested = SubscriptionFilter {
tools_list_changed: requested_flags[TOOLS].then_some(true),
prompts_list_changed: requested_flags[PROMPTS].then_some(true),
resources_list_changed: requested_flags[RESOURCES_LIST].then_some(true),
resource_subscriptions: requested_flags[RESOURCES_SUB]
.then(|| vec![uri.clone()]),
};
let capabilities = caps(supported_flags);
let agreed = requested.intersect_with_capabilities(&capabilities);
let kinds = [
(
SubscriptionNotificationKind::ToolsListChanged,
TOOLS,
),
(
SubscriptionNotificationKind::PromptsListChanged,
PROMPTS,
),
(
SubscriptionNotificationKind::ResourcesListChanged,
RESOURCES_LIST,
),
(
SubscriptionNotificationKind::ResourceUpdated(uri),
RESOURCES_SUB,
),
];
for (kind, index) in kinds {
proptest::prop_assert_eq!(
agreed.covers(&kind),
requested_flags[index] && supported_flags[index],
"agreed filter must be exactly requested AND supported for {:?}",
kind
);
}
let any_agreed = (0..4).any(|i| requested_flags[i] && supported_flags[i]);
proptest::prop_assert_eq!(!agreed.is_empty(), any_agreed);
proptest::prop_assert_eq!(
advertises_subscriptions(&capabilities),
supported_flags.iter().any(|f| *f)
);
}
}
#[test]
fn the_agreed_filter_is_never_a_superset_of_the_request() {
let requested = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
let agreed = requested.intersect_with_capabilities(&caps([true, true, true, true]));
assert_eq!(agreed.tools_list_changed, Some(true));
assert_eq!(agreed.prompts_list_changed, None);
assert_eq!(agreed.resources_list_changed, None);
assert_eq!(agreed.resource_subscriptions, None);
}
#[test]
fn an_unsupported_request_is_omitted_from_the_agreed_filter() {
let requested = SubscriptionFilter {
tools_list_changed: Some(true),
prompts_list_changed: Some(true),
resources_list_changed: Some(true),
resource_subscriptions: Some(vec!["mem://a".to_string()]),
};
let agreed = requested.intersect_with_capabilities(&caps([true, false, false, false]));
assert_eq!(agreed.tools_list_changed, Some(true));
assert_eq!(agreed.prompts_list_changed, None, "omitted, not `false`");
assert_eq!(agreed.resources_list_changed, None);
assert_eq!(agreed.resource_subscriptions, None);
assert!(!agreed.is_empty());
}
#[test]
fn an_entirely_unsupported_request_agrees_to_nothing() {
let requested = SubscriptionFilter {
prompts_list_changed: Some(true),
..SubscriptionFilter::default()
};
let agreed = requested.intersect_with_capabilities(&caps([true, false, false, false]));
assert!(agreed.is_empty());
assert_eq!(
serde_json::to_value(&agreed).expect("serializes"),
json!({})
);
}
#[test]
fn resource_subscriptions_survive_only_with_the_subscribe_capability() {
let requested = SubscriptionFilter {
resource_subscriptions: Some(vec!["mem://a".to_string()]),
..SubscriptionFilter::default()
};
assert_eq!(
requested
.intersect_with_capabilities(&caps([false, false, false, true]))
.resource_subscriptions
.as_deref(),
Some(&["mem://a".to_string()][..])
);
assert_eq!(
requested
.intersect_with_capabilities(&caps([false, false, true, false]))
.resource_subscriptions,
None,
"resources.listChanged does NOT imply resources.subscribe"
);
}
#[test]
fn covers_matches_only_the_requested_kinds() {
let agreed = SubscriptionFilter {
tools_list_changed: Some(true),
resource_subscriptions: Some(vec!["mem://a".to_string()]),
..SubscriptionFilter::default()
};
assert!(agreed.covers(&SubscriptionNotificationKind::ToolsListChanged));
assert!(!agreed.covers(&SubscriptionNotificationKind::PromptsListChanged));
assert!(!agreed.covers(&SubscriptionNotificationKind::ResourcesListChanged));
assert!(
agreed.covers(&SubscriptionNotificationKind::ResourceUpdated(
"mem://a".to_string()
))
);
assert!(
!agreed.covers(&SubscriptionNotificationKind::ResourceUpdated(
"mem://other".to_string()
)),
"a URI the client did not name is not covered"
);
}
#[test]
fn request_scoped_notifications_have_no_subscription_kind() {
use crate::types::ProgressNotification;
use crate::types::ProgressToken;
assert!(
subscription_kind_of(&ServerNotification::Progress(ProgressNotification::new(
ProgressToken::String("t".to_string()),
1.0,
None
)))
.is_none(),
"`notifications/progress` is request-scoped and never subscription-delivered"
);
assert!(
subscription_kind_of(&ServerNotification::LogMessage(LogMessageParams::new(
LoggingLevel::Info,
"hi"
)))
.is_none(),
"`notifications/message` is request-scoped and never subscription-delivered"
);
assert!(subscription_kind_of(&ServerNotification::RootsListChanged).is_none());
}
#[test]
fn subscription_deliverable_notifications_classify() {
assert_eq!(
subscription_kind_of(&ServerNotification::ToolsChanged),
Some(SubscriptionNotificationKind::ToolsListChanged)
);
assert_eq!(
subscription_kind_of(&ServerNotification::PromptsChanged),
Some(SubscriptionNotificationKind::PromptsListChanged)
);
assert_eq!(
subscription_kind_of(&ServerNotification::ResourcesChanged),
Some(SubscriptionNotificationKind::ResourcesListChanged)
);
assert_eq!(
subscription_kind_of(&ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new("mem://a")
)),
Some(SubscriptionNotificationKind::ResourceUpdated(
"mem://a".to_string()
))
);
}
}