Skip to main content

mcp_trace_validator/checks/draft/
versioning.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `2026-07-28` versioning and cross-era compatibility checks.
5//!
6//! The page states three rules a recorded session can bear on: what a client
7//! does *after* being told which versions a server supports, the grammar of an
8//! extension identifier, and what a modern-only server owes a legacy client it
9//! has just refused. The remaining clauses bind extension-defined behaviour,
10//! extension documentation, and client-side caching whose horizon outlives the
11//! session; each carries a documented exclusion in the registry.
12//!
13//! Two of the page's clauses restate rules another page already states, and
14//! name that page's check rather than a copy: VERS-001 shares
15//! `transport.unsupported-version-error` (which was split from its HTTP-status
16//! half precisely so this quote, which mentions no status, is not judged by
17//! one) and VERS-003 shares `discover.implemented`.
18
19use std::collections::BTreeSet;
20
21use mcp_conformance_core::message::MessageKind;
22use mcp_conformance_core::revision::ProtocolRevision;
23use mcp_conformance_core::trace::Direction;
24use serde_json::Value;
25
26use super::super::FindingSink;
27use super::super::base::validate_meta_key;
28use crate::context::TraceContext;
29
30#[cfg(test)]
31mod tests;
32
33/// `UnsupportedProtocolVersionError`.
34const UNSUPPORTED_VERSION: i64 = -32022;
35
36/// The `_meta` field a request declares its protocol version in.
37const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
38
39/// The `_meta` field carrying the client's per-request capabilities.
40const META_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
41
42/// The removed handshake a legacy client opens with.
43const INITIALIZE: &str = "initialize";
44
45/// The protocol version a request's `_meta` declares.
46fn declared_version(payload: &Value) -> Option<&str> {
47    payload
48        .get("params")?
49        .get("_meta")?
50        .get(META_PROTOCOL_VERSION)?
51        .as_str()
52}
53
54/// The versions an `UnsupportedProtocolVersionError` listed, when it listed any.
55///
56/// An empty or absent list is not treated as "no versions supported" — it is a
57/// malformed error, which `transport.unsupported-version-error` reports against
58/// the *server*. Judging the client on it would report a second party for the
59/// first party's defect.
60fn supported_from_error(payload: &Value) -> Option<BTreeSet<String>> {
61    let error = payload.get("error")?;
62    if error.get("code")?.as_i64()? != UNSUPPORTED_VERSION {
63        return None;
64    }
65    let listed: BTreeSet<String> = error
66        .get("data")?
67        .get("supported")?
68        .as_array()?
69        .iter()
70        .filter_map(|version| version.as_str().map(str::to_owned))
71        .collect();
72    (!listed.is_empty()).then_some(listed)
73}
74
75/// `VERS-002`: after being told which versions a server supports, a client's
76/// requests use one of them.
77///
78/// The clause offers two branches — retry with a mutually supported version, or
79/// surface an error — and only one of them puts anything on the wire. So this
80/// judges the branch that does: a request sent *after* a `-32022` that listed
81/// `data.supported`, declaring a version outside that list. The other branch
82/// (the client stops) is indistinguishable from a session that simply ended,
83/// and is not reported.
84///
85/// Every later request is judged, not only the immediate retry: "select a
86/// mutually supported version" is not satisfied by a client that retries
87/// correctly once and then reverts. A second `-32022` replaces the list, since
88/// the newest statement is the server's current one.
89pub(in crate::checks) fn retry_uses_supported_version(
90    context: &TraceContext<'_>,
91    sink: &mut FindingSink,
92) {
93    let mut supported: Option<(u64, BTreeSet<String>)> = None;
94    for (event, kind, _) in context.messages() {
95        let Some(payload) = event.message_payload() else {
96            continue;
97        };
98        match event.direction {
99            Direction::ServerToClient => {
100                if let Some(listed) = supported_from_error(payload) {
101                    supported = Some((event.seq, listed));
102                }
103            }
104            Direction::ClientToServer => {
105                let (MessageKind::Request { .. }, Some((error_seq, listed))) =
106                    (kind, supported.as_ref())
107                else {
108                    continue;
109                };
110                let Some(requested) = declared_version(payload) else {
111                    continue;
112                };
113                // The subject is a request sent *after* the server stated its
114                // versions: before that statement there is nothing to select
115                // from, and a session that drew no such error is untested.
116                sink.examined();
117                if !listed.contains(requested) {
118                    sink.push(
119                        Some(event.seq),
120                        format!(
121                            "the request declares protocol version {requested:?}, which the \
122                             `supported` list in the {UNSUPPORTED_VERSION} at seq {error_seq} \
123                             does not offer ({})",
124                            listed
125                                .iter()
126                                .map(|version| format!("{version:?}"))
127                                .collect::<Vec<_>>()
128                                .join(", ")
129                        ),
130                    );
131                }
132            }
133        }
134    }
135}
136
137/// Every extension identifier the trace advertises, as `(seq, surface, identifier)`.
138///
139/// The revision has exactly two capability surfaces, and this reads both: a
140/// request's `_meta` client capabilities, and a `server/discover` result's
141/// capabilities. The clause binds `both` actors, so judging only one side would
142/// silently exempt the other.
143fn extension_identifiers<'a>(
144    context: &'a TraceContext<'_>,
145) -> Vec<(u64, &'static str, &'a String)> {
146    let mut out = Vec::new();
147    let mut push = |seq, surface, capabilities: Option<&'a Value>| {
148        let extensions = capabilities
149            .and_then(|capabilities| capabilities.get("extensions"))
150            .and_then(Value::as_object);
151        if let Some(extensions) = extensions {
152            out.extend(extensions.keys().map(|id| (seq, surface, id)));
153        }
154    };
155    for (event, kind, _) in context.messages() {
156        let Some(payload) = event.message_payload() else {
157            continue;
158        };
159        match (event.direction, kind) {
160            (
161                Direction::ClientToServer,
162                MessageKind::Request { .. } | MessageKind::Notification { .. },
163            ) => push(
164                event.seq,
165                "client capabilities",
166                payload
167                    .get("params")
168                    .and_then(|params| params.get("_meta"))
169                    .and_then(|meta| meta.get(META_CLIENT_CAPABILITIES)),
170            ),
171            (Direction::ServerToClient, MessageKind::Result { .. }) => push(
172                event.seq,
173                "server capabilities",
174                payload
175                    .get("result")
176                    .and_then(|result| result.get("capabilities")),
177            ),
178            _ => {}
179        }
180    }
181    out
182}
183
184/// `VERS-004`: an extension identifier is a `_meta` key with a mandatory prefix.
185///
186/// The grammar half reuses [`validate_meta_key`] — the function, not the
187/// `base.meta-key-format` check, which reads `_meta` keys in message envelopes
188/// and would inspect no extension identifier at all. The clause's own addition
189/// is the prefix: optional in a `_meta` key, required here, and the only way to
190/// carry one is the `label(.label)*/` form the grammar already defines.
191pub(in crate::checks) fn extension_identifier_format(
192    context: &TraceContext<'_>,
193    sink: &mut FindingSink,
194) {
195    for (seq, surface, identifier) in extension_identifiers(context) {
196        sink.examined();
197        if let Err(reason) = validate_meta_key(identifier) {
198            sink.push(
199                Some(seq),
200                format!("{surface} extension identifier {identifier:?} {reason}"),
201            );
202        } else if !identifier.contains('/') {
203            sink.push(
204                Some(seq),
205                format!(
206                    "{surface} extension identifier {identifier:?} has no prefix; the prefix \
207                     is optional in a `_meta` key but mandatory for an extension identifier"
208                ),
209            );
210        }
211    }
212}
213
214/// Whether any string inside `value` contains a protocol-revision-shaped token.
215///
216/// Substring rather than whole-string, because the clause asks the server to
217/// *name* its versions and says nothing about where: a `data.supported` array
218/// and a `message` reading "this server speaks 2026-07-28" both name them.
219fn names_a_revision(value: &Value) -> bool {
220    match value {
221        Value::String(text) => contains_revision(text),
222        Value::Array(items) => items.iter().any(names_a_revision),
223        Value::Object(members) => members.values().any(names_a_revision),
224        _ => false,
225    }
226}
227
228/// Whether `text` contains a `YYYY-MM-DD` token that is a real protocol revision.
229fn contains_revision(text: &str) -> bool {
230    (0..text.len()).any(|start| {
231        text.get(start..start + 10)
232            .is_some_and(|window| window.parse::<ProtocolRevision>().is_ok())
233    })
234}
235
236/// `VERS-008`: a modern-only server names its versions when it refuses an
237/// `initialize`.
238///
239/// The antecedent is witnessed by the refusal itself: a server that answers
240/// `initialize` with an *error* is not serving the legacy era, and the trace is
241/// being judged as a `2026-07-28` session — the same premise under which
242/// DISC-001 reports a server that has no `server/discover`. A dual-era server
243/// answers the handshake with a result and is never reached here.
244///
245/// "Name the protocol versions" is read as: some string in the error object
246/// contains a real revision token. That admits both shapes a server might use —
247/// `data.supported`, or a human-readable `message` — because the clause's
248/// purpose is the diagnostic a legacy client can surface, not a wire format.
249pub(in crate::checks) fn initialize_error_names_versions(
250    context: &TraceContext<'_>,
251    sink: &mut FindingSink,
252) {
253    for exchange in context.exchanges_for(INITIALIZE) {
254        if exchange.request.direction != Direction::ClientToServer {
255            continue;
256        }
257        let Some(error) = exchange
258            .response
259            .message_payload()
260            .and_then(|payload| payload.get("error"))
261        else {
262            continue;
263        };
264        // The subject is a *refused* `initialize`: the antecedent — a server
265        // that does not serve the legacy era — is witnessed by the refusal.
266        sink.examined();
267        if !names_a_revision(error) {
268            sink.push(
269                Some(exchange.response.seq),
270                "the error refusing `initialize` names no protocol version, leaving a legacy \
271                 client — which has no fall-forward mechanism — nothing to surface"
272                    .to_owned(),
273            );
274        }
275    }
276}