Skip to main content

protocol_droid/application/mcp/
subscription.rs

1//! `subscriptions/listen`, independent of the transport that carries it.
2//!
3//! SEP-2575 (`2026-07-28`) replaced the HTTP `GET` stream and
4//! `resources/subscribe` with one request: the client names the notifications
5//! it wants, and the server acknowledges the subset it will honour and then
6//! tags every notification with `_meta["io.modelcontextprotocol/subscriptionId"]`,
7//! whose value is the JSON-RPC id of the `subscriptions/listen` request. On
8//! Streamable HTTP the notifications ride that request's response stream; on
9//! stdio they share the one channel and the id is how a client tells them
10//! apart. The decisions — what to honour, how to tag, what to acknowledge —
11//! are the same on both, so they live here once.
12//!
13//! - "The server MUST NOT send notification types the client has not
14//!   explicitly requested."
15//! - The acknowledgement lists "the subset of requested notification types the
16//!   server agreed to honor", omitting any it does not support.
17//! - It "MUST be the first message the server sends carrying the
18//!   subscription's ID".
19
20use std::sync::Arc;
21
22use serde_json::{Map, Value};
23
24use crate::application::server::context::PeerSession;
25use crate::application::server::{ListKind, ListSubscription, Server};
26use crate::application::session::DynFrameSink;
27use crate::domain::Feature;
28
29/// The `_meta` key every message on a subscription carries.
30pub const SUBSCRIPTION_ID_KEY: &str = "io.modelcontextprotocol/subscriptionId";
31
32/// The filter members, with the feature each needs and the kind it maps to.
33const FILTER: [(&str, Feature, ListKind); 3] = [
34    (
35        "toolsListChanged",
36        Feature::ToolsListChanged,
37        ListKind::Tools,
38    ),
39    (
40        "promptsListChanged",
41        Feature::PromptsListChanged,
42        ListKind::Prompts,
43    ),
44    (
45        "resourcesListChanged",
46        Feature::ResourcesListChanged,
47        ListKind::Resources,
48    ),
49];
50
51/// What this server agreed to send on one subscription.
52#[derive(Debug, Clone, Default)]
53pub struct Honoured {
54    /// The `SubscriptionFilter` the acknowledgement reports.
55    pub filter: Map<String, Value>,
56    /// The list notifications to subscribe to.
57    pub kinds: Vec<ListKind>,
58    /// The resource URIs to watch for `notifications/resources/updated`.
59    pub resources: Vec<String>,
60}
61
62/// Decides which of `requested` this server will honour for `peer`.
63///
64/// A member is honoured when the client asked for it and the request's own
65/// profile allows it. A resource URI is honoured only for a resource this
66/// server lists, because a watch on a URI it does not have is a wait for an
67/// update that cannot come.
68#[must_use]
69pub fn honour(requested: &Map<String, Value>, peer: &PeerSession, server: &Server) -> Honoured {
70    let mut honoured = Honoured::default();
71    for (member, feature, kind) in FILTER {
72        if requested.get(member).and_then(Value::as_bool) == Some(true) && peer.allows(feature) {
73            honoured.filter.insert(member.to_owned(), Value::Bool(true));
74            honoured.kinds.push(kind);
75        }
76    }
77    if peer.allows(Feature::ResourcesSubscribe) {
78        honoured.resources = requested
79            .get("resourceSubscriptions")
80            .and_then(Value::as_array)
81            .into_iter()
82            .flatten()
83            .filter_map(Value::as_str)
84            .filter(|uri| server.reader(uri).is_some())
85            .map(ToOwned::to_owned)
86            .collect();
87    }
88    if !honoured.resources.is_empty() {
89        honoured.filter.insert(
90            "resourceSubscriptions".to_owned(),
91            Value::Array(
92                honoured
93                    .resources
94                    .iter()
95                    .cloned()
96                    .map(Value::String)
97                    .collect(),
98            ),
99        );
100    }
101    honoured
102}
103
104/// The `_meta` object tagging a subscription opened by request `id`.
105#[must_use]
106pub fn tag(id: &Value) -> Value {
107    serde_json::json!({ SUBSCRIPTION_ID_KEY: id })
108}
109
110/// The `notifications/subscriptions/acknowledged` frame.
111#[must_use]
112pub fn acknowledgement(honoured: &Honoured, meta: &Value) -> Value {
113    serde_json::json!({
114        "jsonrpc": "2.0",
115        "method": "notifications/subscriptions/acknowledged",
116        "params": {
117            "notifications": honoured.filter,
118            "_meta": meta,
119        },
120    })
121}
122
123/// Subscribes `sink` to everything `honoured` names, tagged with `meta`.
124///
125/// Call it only after the acknowledgement has been written, so no change can
126/// reach the client ahead of it. The returned guard ends both the list and the
127/// resource subscriptions when it is dropped.
128#[must_use]
129pub fn start(
130    server: &Server,
131    sink: &Arc<dyn DynFrameSink>,
132    peer: &PeerSession,
133    honoured: &Honoured,
134    meta: &Value,
135) -> ListSubscription {
136    let handle = server.handle();
137    let subscription = handle.subscribe(sink, peer.profile(), &honoured.kinds, Some(meta));
138    for uri in &honoured.resources {
139        handle.watch_resource(sink, uri, Some(meta));
140    }
141    subscription
142}