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}