mcp_trace_validator/checks/draft/subscriptions.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `subscriptions/listen`: the long-lived notification stream that replaced
5//! `resources/subscribe` and the HTTP GET endpoint.
6//!
7//! Every message belonging to a subscription carries the `subscriptions/listen`
8//! request's own JSON-RPC id in `_meta.io.modelcontextprotocol/subscriptionId`,
9//! which is what makes these checks possible on stdio, where every subscription
10//! shares one channel. They correlate on that field and on nothing else — never
11//! on message order alone, because the specification explicitly permits other
12//! subscriptions' messages to interleave.
13//!
14//! Three of the page's seven clauses carry exclusions: two bind what the client
15//! does with what it received (compare the filter, demultiplex the stream), and
16//! one begins after a reconnection, which is a second recording.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use mcp_conformance_core::trace::{Direction, TraceEvent};
21use serde_json::{Map, Value};
22
23use super::super::FindingSink;
24use crate::context::TraceContext;
25
26#[cfg(test)]
27mod tests;
28
29/// The request that opens a subscription.
30const LISTEN: &str = "subscriptions/listen";
31
32/// The `_meta` key tying a message to its subscription.
33const SUBSCRIPTION_ID: &str = "io.modelcontextprotocol/subscriptionId";
34
35/// The acknowledgment that must open every subscription.
36const ACKNOWLEDGED: &str = "notifications/subscriptions/acknowledged";
37
38/// Each notification type, and the filter field that requests it.
39const FILTERED: &[(&str, &str)] = &[
40 ("notifications/tools/list_changed", "toolsListChanged"),
41 ("notifications/prompts/list_changed", "promptsListChanged"),
42 (
43 "notifications/resources/list_changed",
44 "resourcesListChanged",
45 ),
46];
47
48/// The filter field listing the resource URIs whose updates were requested.
49const RESOURCE_SUBSCRIPTIONS: &str = "resourceSubscriptions";
50
51/// The notification type `RESOURCE_SUBSCRIPTIONS` governs.
52const RESOURCE_UPDATED: &str = "notifications/resources/updated";
53
54/// An open subscription: the id its messages are tagged with, and its filter.
55#[derive(Debug, Clone, Copy)]
56struct Subscription<'a> {
57 /// The `subscriptions/listen` request's id, in canonical text.
58 seq: u64,
59 /// The `notifications` filter, when the request carried one.
60 filter: Option<&'a Map<String, Value>>,
61}
62
63/// Every `subscriptions/listen` request in the trace, keyed by its id text.
64fn subscriptions<'a>(context: &'a TraceContext<'_>) -> BTreeMap<String, Subscription<'a>> {
65 context
66 .messages()
67 .filter_map(|(event, _, _)| {
68 if event.direction != Direction::ClientToServer {
69 return None;
70 }
71 let payload = event.message_payload()?;
72 if payload.get("method")?.as_str()? != LISTEN {
73 return None;
74 }
75 let id = payload.get("id").filter(|id| !id.is_null())?;
76 Some((
77 id.to_string(),
78 Subscription {
79 seq: event.seq,
80 filter: payload
81 .get("params")
82 .and_then(|params| params.get("notifications"))
83 .and_then(Value::as_object),
84 },
85 ))
86 })
87 .collect()
88}
89
90/// Server messages tagged with a subscription id, as `(seq, id text, method)`.
91///
92/// Both notifications and the closing response are tagged, so the method is
93/// `None` for the response — which is exactly what distinguishes it.
94fn tagged<'a>(context: &'a TraceContext<'_>) -> Vec<(u64, String, Option<&'a str>, &'a Value)> {
95 context
96 .messages()
97 .filter_map(|(event, _, _)| tagged_message(event))
98 .collect()
99}
100
101/// The subscription tag one server message carries, if any.
102fn tagged_message(event: &TraceEvent) -> Option<(u64, String, Option<&str>, &Value)> {
103 if event.direction != Direction::ServerToClient {
104 return None;
105 }
106 let payload = event.message_payload()?;
107 let params = payload.get("params").or_else(|| payload.get("result"))?;
108 let id = params.get("_meta")?.get(SUBSCRIPTION_ID)?;
109 Some((
110 event.seq,
111 id.to_string(),
112 payload.get("method").and_then(Value::as_str),
113 params,
114 ))
115}
116
117/// `SUBS-001`: only the notification types the filter asked for.
118///
119/// A filter field that is absent is a type not subscribed to — the page says so
120/// outright ("Omitting a field is equivalent to not subscribing") — so a
121/// subscription with no `notifications` object at all has requested nothing, and
122/// every notification on it but the acknowledgment is unrequested.
123pub(in crate::checks) fn only_requested_notifications(
124 context: &TraceContext<'_>,
125 sink: &mut FindingSink,
126) {
127 let subscriptions = subscriptions(context);
128 for (seq, id, method, params) in tagged(context) {
129 let (Some(method), Some(subscription)) = (method, subscriptions.get(&id)) else {
130 continue;
131 };
132 if method == ACKNOWLEDGED {
133 continue;
134 }
135 // The subject is a notification delivered on a subscription; the
136 // acknowledgment is the stream's own opening and not filtered content.
137 sink.examined();
138 if let Some(reason) = unrequested(subscription.filter, method, params) {
139 sink.push(
140 Some(seq),
141 format!("subscription {id} was sent `{method}`, which {reason}"),
142 );
143 }
144 }
145}
146
147/// Why `method` was not requested by `filter`, or `None` when it was.
148fn unrequested(
149 filter: Option<&Map<String, Value>>,
150 method: &str,
151 params: &Value,
152) -> Option<String> {
153 if let Some((_, field)) = FILTERED.iter().find(|(name, _)| *name == method) {
154 let asked = filter
155 .and_then(|filter| filter.get(*field))
156 .is_some_and(|value| value.as_bool() == Some(true));
157 return (!asked).then(|| format!("its filter did not set `{field}`"));
158 }
159 if method == RESOURCE_UPDATED {
160 let uri = params
161 .get("uri")
162 .and_then(Value::as_str)
163 .unwrap_or_default();
164 let listed = filter
165 .and_then(|filter| filter.get(RESOURCE_SUBSCRIPTIONS))
166 .and_then(Value::as_array)
167 .is_some_and(|uris| uris.iter().any(|value| value.as_str() == Some(uri)));
168 return (!listed)
169 .then(|| format!("its `{RESOURCE_SUBSCRIPTIONS}` does not list the URI {uri:?}"));
170 }
171 Some("is not one of the notification types the filter can request".to_owned())
172}
173
174/// `SUBS-002`: the acknowledgment is a subscription's first message.
175///
176/// Judged per subscription id rather than per channel, which is the distinction
177/// the page draws for stdio: another subscription's messages may interleave
178/// ahead of this one's acknowledgment without breaking anything.
179///
180/// A subscription with no tagged message at all is not reported — a recording
181/// that ends before the acknowledgment arrives is not evidence that it never
182/// did. What is falsifiable is a *first* tagged message that is something else.
183pub(in crate::checks) fn acknowledgment_first(context: &TraceContext<'_>, sink: &mut FindingSink) {
184 let subscriptions = subscriptions(context);
185 // One ordered pass, deciding each subscription at its first tagged message.
186 // The alternative — filtering the tagged messages by `tag == id && seq >
187 // listen.seq` — carried two comparisons a trace can never exercise: a
188 // subscription's messages are server-sent and its `subscriptions/listen` is
189 // client-sent, so no two share a `seq`, making `>` and `>=` the same rule.
190 let mut open: BTreeMap<String, u64> = BTreeMap::new();
191 let mut decided: BTreeSet<String> = BTreeSet::new();
192 for (event, _, _) in context.messages() {
193 if let Some((id, subscription)) = subscriptions
194 .iter()
195 .find(|(_, subscription)| subscription.seq == event.seq)
196 {
197 open.insert(id.clone(), subscription.seq);
198 continue;
199 }
200 let Some((seq, id, method, _)) = tagged_message(event) else {
201 continue;
202 };
203 if !open.contains_key(&id) || !decided.insert(id.clone()) {
204 continue;
205 }
206 // The subject is a subscription's *first* tagged message; a
207 // subscription that produced none is undecided, not conforming.
208 sink.examined();
209 match method {
210 Some(ACKNOWLEDGED) => {}
211 Some(other) => sink.push(
212 Some(seq),
213 format!(
214 "subscription {id} opened with `{other}`; `{ACKNOWLEDGED}` must be its \
215 first message"
216 ),
217 ),
218 None => sink.push(
219 Some(seq),
220 format!(
221 "subscription {id} was closed by its `{LISTEN}` response before any \
222 `{ACKNOWLEDGED}` was sent"
223 ),
224 ),
225 }
226 }
227}
228
229/// `SUBS-005` and `SUBS-006`: a graceful closure's result carries no
230/// method-specific data.
231///
232/// The clauses' leading obligation — *send* a response before closing — has no
233/// falsifier, and the page says why in its own words: "a transport that closes
234/// without it indicates an unexpected disconnect". A close with no response is
235/// therefore not a missing response but a different ending, and no trace
236/// separates the two. What is judged is the half that is on the wire when the
237/// server does respond: the result carries "no method-specific data beyond the
238/// standard result fields and subscription metadata" — `resultType` and the
239/// `_meta` that names the subscription, and nothing else.
240///
241/// **Re-reviewed 2026-08-20**, when the drift gate caught both quotes moving.
242/// The page changed "the empty response" to "a successful response" and "an
243/// empty result" to "a completion result", and added the sentence quoted above.
244/// The obligation did not change: this check already permitted exactly
245/// `resultType` and `_meta`, which the new prose now states outright instead of
246/// leaving "empty" to be read. `resultType: "complete"` is *not* additionally
247/// enforced — it appears only in the page's example, and a value shown in an
248/// example with no RFC 2119 keyword is not a clause this registry enters
249/// (03-conformance-strategy §What enters the registry, rule 3).
250pub(in crate::checks) fn graceful_close_result_shape(
251 context: &TraceContext<'_>,
252 sink: &mut FindingSink,
253) {
254 for exchange in context.exchanges_for(LISTEN) {
255 let Some(result) = exchange.result.and_then(Value::as_object) else {
256 continue;
257 };
258 sink.examined();
259 let extra: Vec<&String> = result
260 .keys()
261 .filter(|key| *key != "resultType" && *key != "_meta")
262 .collect();
263 if !extra.is_empty() {
264 sink.push(
265 Some(exchange.response.seq),
266 format!(
267 "the `{LISTEN}` response carries {}; a graceful closure's result \
268 carries no method-specific data beyond `resultType` and `_meta`",
269 extra
270 .iter()
271 .map(|key| format!("`{key}`"))
272 .collect::<Vec<_>>()
273 .join(", ")
274 ),
275 );
276 }
277 }
278}