1use std::collections::BTreeSet;
55use std::str::FromStr as _;
56
57use mcp_conformance_core::requirement::RegistrySet;
58use mcp_conformance_core::revision::ProtocolRevision;
59use mcp_conformance_core::trace::{EventBody, TraceEvent};
60use serde_json::Value;
61
62const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
64
65const PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version";
67
68#[must_use]
75pub fn declared_revisions(events: &[TraceEvent]) -> Vec<String> {
76 collect(events)
77 .into_iter()
78 .map(|revision| revision.to_string())
79 .collect()
80}
81
82fn collect(events: &[TraceEvent]) -> BTreeSet<ProtocolRevision> {
84 let refused = refused_request_ids(events);
85 let mut found: BTreeSet<ProtocolRevision> = BTreeSet::new();
86 let mut pending_header: Option<&str> = None;
87 for event in events {
88 match &event.body {
89 EventBody::Message { payload } => {
90 if is_refused(payload, &refused) {
99 pending_header = None;
100 continue;
101 }
102 collect_from_message(payload, &mut found);
103 if let Some(value) = pending_header.take() {
106 insert(value, &mut found);
107 }
108 }
109 EventBody::Http { headers, .. } => {
110 if let Some(value) = pending_header.take() {
114 insert(value, &mut found);
115 }
116 pending_header = headers.get(PROTOCOL_VERSION_HEADER).map(String::as_str);
117 }
118 _ => {}
119 }
120 }
121 if let Some(value) = pending_header {
122 insert(value, &mut found);
123 }
124 found
125}
126
127fn refused_request_ids(events: &[TraceEvent]) -> BTreeSet<String> {
129 events
130 .iter()
131 .filter_map(|event| event.message_payload())
132 .filter(|payload| payload.get("error").is_some())
133 .filter_map(|payload| payload.get("id"))
134 .map(ToString::to_string)
135 .collect()
136}
137
138fn is_refused(payload: &Value, refused: &BTreeSet<String>) -> bool {
140 payload.get("method").is_some()
141 && payload
142 .get("id")
143 .is_some_and(|id| refused.contains(&id.to_string()))
144}
145
146#[must_use]
149pub fn mismatch(registry_revision: ProtocolRevision, events: &[TraceEvent]) -> Option<Vec<String>> {
150 let declared = collect(events);
151 if declared.is_empty() || declared.contains(®istry_revision) {
152 return None;
153 }
154 Some(
155 declared
156 .into_iter()
157 .map(|revision| revision.to_string())
158 .collect(),
159 )
160}
161
162#[must_use]
165pub fn mismatch_any(
166 registry_revisions: &[ProtocolRevision],
167 events: &[TraceEvent],
168) -> Option<Vec<String>> {
169 let declared = collect(events);
170 if declared.is_empty()
171 || registry_revisions
172 .iter()
173 .any(|revision| declared.contains(revision))
174 {
175 return None;
176 }
177 Some(
178 declared
179 .into_iter()
180 .map(|revision| revision.to_string())
181 .collect(),
182 )
183}
184
185fn collect_from_message(payload: &Value, found: &mut BTreeSet<ProtocolRevision>) {
186 for envelope in [payload.get("params"), payload.get("result")]
192 .into_iter()
193 .flatten()
194 {
195 if let Some(Value::String(version)) = envelope.get("protocolVersion") {
196 insert(version, found);
197 }
198 if let Some(Value::String(version)) = envelope
199 .get("_meta")
200 .and_then(|meta| meta.get(META_PROTOCOL_VERSION))
201 {
202 insert(version, found);
203 }
204 }
205}
206
207fn insert(value: &str, found: &mut BTreeSet<ProtocolRevision>) {
208 if let Ok(revision) = ProtocolRevision::from_str(value)
209 && is_known(revision)
210 {
211 found.insert(revision);
212 }
213}
214
215fn is_known(revision: ProtocolRevision) -> bool {
220 RegistrySet::builtin().is_ok_and(|set| set.revisions().contains(&revision))
221}
222
223#[cfg(test)]
224#[allow(clippy::unwrap_used)]
225mod tests {
226 use super::*;
227 use crate::reader::{Limits, parse_trace};
228
229 fn events(document: &str) -> Vec<TraceEvent> {
230 parse_trace(document, &Limits::default()).unwrap()
231 }
232
233 fn rev(revision: &str) -> ProtocolRevision {
234 revision.parse().unwrap()
235 }
236
237 const HANDSHAKE: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}
238{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}"#;
239
240 #[test]
241 fn the_handshake_states_the_revision_from_both_ends() {
242 assert_eq!(declared_revisions(&events(HANDSHAKE)), ["2025-11-25"]);
243 assert!(mismatch(rev("2025-11-25"), &events(HANDSHAKE)).is_none());
244 }
245
246 #[test]
250 #[cfg(feature = "draft-2026-07-28")]
251 fn a_stateless_session_states_it_per_request() {
252 let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}}"#;
253 assert_eq!(declared_revisions(&events(document)), ["2026-07-28"]);
254 assert_eq!(
255 mismatch(rev("2025-11-25"), &events(document)),
256 Some(vec!["2026-07-28".to_owned()])
257 );
258 }
259
260 #[test]
261 fn the_http_header_states_it_too() {
262 let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"2025-11-25"}}"#;
263 assert_eq!(declared_revisions(&events(document)), ["2025-11-25"]);
264 }
265
266 #[test]
270 #[cfg(feature = "draft-2026-07-28")]
271 fn a_session_that_touched_the_registrys_revision_draws_no_note() {
272 let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}
275{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}"#;
276 assert_eq!(
277 declared_revisions(&events(document)),
278 ["2025-11-25", "2026-07-28"]
279 );
280 assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
281 assert!(mismatch(rev("2026-07-28"), &events(document)).is_none());
282 }
283
284 #[test]
285 fn a_session_that_declares_nothing_draws_no_note() {
286 let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
287 assert!(declared_revisions(&events(document)).is_empty());
288 assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
289 }
290
291 #[test]
292 fn a_malformed_version_is_not_evidence_of_a_revision() {
293 let document = r#"{"seq":0,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"draft","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}"#;
296 assert!(declared_revisions(&events(document)).is_empty());
297 assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
298 }
299
300 #[test]
301 fn a_version_this_build_cannot_judge_is_not_a_declaration() {
302 let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"1900-01-01"}}
305{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"1900-01-01"}}}}"#;
306 assert!(declared_revisions(&events(document)).is_empty());
307 assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
308 }
309
310 #[test]
314 #[cfg(feature = "draft-2026-07-28")]
315 fn a_refused_request_states_no_revision() {
316 let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}
321{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}}"#;
322 assert!(declared_revisions(&events(document)).is_empty());
323 assert!(mismatch(rev("2026-07-28"), &events(document)).is_none());
324 }
325
326 #[test]
327 fn a_headers_only_capture_still_states_its_revision() {
328 let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"2025-11-25"}}
331{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"method":"tools/list"}}
332{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"result":{"tools":[]}}}"#;
333 assert_eq!(declared_revisions(&events(document)), ["2025-11-25"]);
334 }
335
336 #[test]
337 fn a_refused_request_takes_its_own_header_down_with_it() {
338 let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"2025-11-25"}}
339{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"method":"tools/list"}}
340{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"error":{"code":-32022,"message":"Unsupported protocol version"}}}"#;
341 assert!(declared_revisions(&events(document)).is_empty());
342 }
343
344 #[test]
345 fn a_non_string_version_is_ignored_rather_than_stringified() {
346 let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":20251125}}}"#;
347 assert!(declared_revisions(&events(document)).is_empty());
348 }
349
350 #[test]
354 #[cfg(feature = "draft-2026-07-28")]
355 fn declarations_are_deduplicated_and_ordered() {
356 let document = format!(
357 "{HANDSHAKE}\n{}",
358 r#"{"seq":2,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}}"#
359 );
360 assert_eq!(
361 declared_revisions(&events(&document)),
362 ["2025-11-25", "2026-07-28"]
363 );
364 }
365}