Skip to main content

mcp_trace_validator/
declared.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! What protocol revision a session says it is, read from the session itself.
5//!
6//! A registry judges one revision. Point the validator at a recording of a
7//! *different* one and every clause the two revisions disagree about becomes a
8//! finding — confidently, with a verbatim spec quote, against an
9//! implementation that violated nothing. A conforming `2026-07-28` stateless
10//! session judged against `2025-11-25` fails `LIFE-001` for not opening with
11//! `initialize`, which `2026-07-28` removes (SEP-2575), and `BASE-003` for
12//! reusing request ids, which `2026-07-28` permits.
13//!
14//! The trace is not silent about this. Every revision states its own version on
15//! the wire, and a recording carries it:
16//!
17//! - the `initialize` **result**'s `protocolVersion` — the negotiated revision,
18//!   and the authority where there is one;
19//! - the `initialize` **request**'s `protocolVersion` — what the client
20//!   proposed, which is evidence even when no server answered;
21//! - a request's `_meta` `io.modelcontextprotocol/protocolVersion` — how
22//!   `2026-07-28` carries it, per request, having no handshake;
23//! - the `MCP-Protocol-Version` HTTP header.
24//!
25//! So the validator can tell the difference between *this session broke the
26//! rules* and *these are not the rules this session was playing by*, and
27//! [`Report::revision_mismatch`] says which.
28//!
29//! **The rule is deliberately quiet**, in three ways.
30//!
31//! A mismatch is reported only when the session declared at least one revision
32//! and *none* of them is the registry's. A session that proposes one revision
33//! and negotiates another has touched both, so judging it against either is a
34//! question worth asking and draws no note.
35//!
36//! A session that declares nothing at all — a message-level capture of a
37//! handshake that never happened — gets no note either: there is nothing to
38//! disagree with, and inventing a warning from an absence is the vacuous
39//! reasoning this validator refuses everywhere else.
40//!
41//! And a version is only a declaration if the session actually ran under it.
42//! Two filters enforce that. A request the other end answered with a JSON-RPC
43//! **error** states nothing — it named a version and was told no — so a probe
44//! asking for `1900-01-01` and drawing `-32022`, or a legacy `initialize`
45//! drawing `-32601` from a server that no longer has one, are sessions of no
46//! revision at all rather than of the one they asked for; the clauses that
47//! judge those refusals (`TRAN-074`, `VERS-008`) are the ones with something to
48//! say. And only revisions this build ships a registry for count, because the
49//! note exists to send a reader to a registry that exists: *re-run with
50//! `--revision X`* is worthless advice when there is no `X` to run against.
51//!
52//! [`Report::revision_mismatch`]: crate::report::Report::revision_mismatch
53
54use 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
62/// How `2026-07-28` states the revision on each request, having no handshake.
63const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
64
65/// The HTTP header carrying the revision on the Streamable HTTP transport.
66const PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version";
67
68/// Every protocol revision the session states about itself, ascending.
69///
70/// Only well-formed `YYYY-MM-DD` values enter: a malformed `protocolVersion` is
71/// a violation with its own clause (`LIFE-006`), not evidence of which revision
72/// the session belongs to, and treating it as evidence would turn one finding
73/// into two.
74#[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
82/// [`declared_revisions`] before rendering, so comparisons stay typed.
83fn 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                // A request the other end answered with an error asserts
91                // nothing about which rules the session ran under: it names a
92                // version and is told no. Both corpus probes of that shape —
93                // `1900-01-01` refused with `-32022`, and a legacy `initialize`
94                // refused with `-32601` — would otherwise be read as sessions
95                // of a revision that never happened. A response carries the
96                // answer, so it is always evidence; so is a notification, which
97                // has no id to be refused by.
98                if is_refused(payload, &refused) {
99                    pending_header = None;
100                    continue;
101                }
102                collect_from_message(payload, &mut found);
103                // The request's own headers travelled with it, so they stand or
104                // fall together.
105                if let Some(value) = pending_header.take() {
106                    insert(value, &mut found);
107                }
108            }
109            EventBody::Http { headers, .. } => {
110                // Held until the message this request carried is seen: a
111                // partial capture that recorded headers but no handshake still
112                // states its revision this way, and a refused request must not.
113                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
127/// The ids of requests the other end answered with a JSON-RPC error.
128fn 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
138/// Whether this message is a request whose id was answered with an error.
139fn 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/// The revisions a session declared, when it declared some and the registry's
147/// is not among them. `None` means there is nothing to warn about.
148#[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(&registry_revision) {
152        return None;
153    }
154    Some(
155        declared
156            .into_iter()
157            .map(|revision| revision.to_string())
158            .collect(),
159    )
160}
161
162/// [`mismatch`] for a run judging several revisions at once: the note fires
163/// only when none of them is one the session declared.
164#[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    // `initialize` states it in `params` (proposed) and in `result`
187    // (negotiated); `2026-07-28` states it in every request's `params._meta`.
188    // Reading both positions on every message needs no method dispatch and
189    // cannot misattribute: no other member is spelled `protocolVersion` at the
190    // top of an `initialize` envelope, and the `_meta` key is namespaced.
191    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
215/// Whether this build ships a registry for `revision`, and so could be asked to
216/// judge against it. Feature-dependent by construction: a build without
217/// `draft-2026-07-28` cannot judge that revision and therefore has no advice to
218/// offer about a recording of it.
219fn 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    // Needs a second shipped registry: without `draft-2026-07-28` this build
247    // has none, so `2026-07-28` is not a revision it could be asked to judge
248    // and correctly counts as no declaration at all.
249    #[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    // Needs a second shipped registry: without `draft-2026-07-28` this build
267    // has none, so `2026-07-28` is not a revision it could be asked to judge
268    // and correctly counts as no declaration at all.
269    #[test]
270    #[cfg(feature = "draft-2026-07-28")]
271    fn a_session_that_touched_the_registrys_revision_draws_no_note() {
272        // Proposed one revision, negotiated another: judging it against either
273        // is a fair question, so neither draws a warning.
274        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        // LIFE-006's subject, not this module's: a value that is not a dated
294        // revision says nothing about which revision the session belongs to.
295        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        // Well-formed, but no registry ships for it, so `--revision 1900-01-01`
303        // would be advice with nothing behind it.
304        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    // Needs a second shipped registry: without `draft-2026-07-28` this build
311    // has none, so `2026-07-28` is not a revision it could be asked to judge
312    // and correctly counts as no declaration at all.
313    #[test]
314    #[cfg(feature = "draft-2026-07-28")]
315    fn a_refused_request_states_no_revision() {
316        // The `vers-008` corpus trace: a legacy client's `initialize` reaches a
317        // server that no longer implements one. The session ran under no
318        // revision, and `VERS-008` is the clause with something to say about
319        // it — this module must not add "you used the wrong registry" on top.
320        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        // A recording that began mid-session has no handshake to read, but
329        // every request still carries the header.
330        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    // Needs a second shipped registry: without `draft-2026-07-28` this build
351    // has none, so `2026-07-28` is not a revision it could be asked to judge
352    // and correctly counts as no declaration at all.
353    #[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}