protocol_droid/domain/protocol_version.rs
1//! `ProtocolVersion` — the negotiated revision, as a value object.
2//!
3//! # Why `Unknown` carries a date rather than a string
4//!
5//! MCP identifies a revision by its release date and nothing else, so the
6//! forward-compatible case is a well-formed date this build has not heard of.
7//! Holding a bare `String` would mean an `Unknown` could be anything, including
8//! a typo, and would make the comparison a lexicographic one that gets
9//! `2024-11-05 < 2024-2-1` wrong. Holding a [`ReleaseDate`] means every
10//! `ProtocolVersion` that exists is a real, ordered point in time.
11//!
12//! # The ordering invariant
13//!
14//! Both `Ord` and `PartialOrd` are derived, from the same variant order, which
15//! is what the 1.98 derive fast path requires. For that to also be *correct*,
16//! the declaration order has to be chronological — and it only is because of
17//! one rule enforced in [`FromStr`]:
18//!
19//! > A well-formed date at or after the first revision that this build does not
20//! > already know becomes `Unknown`; a well-formed date *before* the first
21//! > revision is a parse error.
22//!
23//! That guarantees every `Unknown` sorts above every known variant, so
24//! `V2024_11_05 < V2025_03_26 < … < V2026_07_28 < Unknown(_)` is true
25//! chronologically as well as by declaration. Without the rule, a
26//! `ProtocolVersion::Unknown` for `2020-01-01` would sort *after* `2026-07-28`
27//! and silently corrupt `latest()` and `best_match`.
28//!
29//! The invariant is a construction-site property, not a type-level one: the
30//! `Unknown` variant is public because callers need to match on it, so a future
31//! edit could build one by hand. `tests::unknown_dates_sort_after_every_known_revision`
32//! is what holds that down.
33
34use core::fmt;
35use core::str::FromStr;
36
37use crate::domain::ReleaseDate;
38
39use crate::domain::release_date::ParseReleaseDateError;
40
41/// A published MCP revision, or one published after this build was compiled.
42///
43/// Ordered oldest to newest, which is also publication order. See the module
44/// docs for why that is an invariant rather than a coincidence.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum ProtocolVersion {
47 /// The first published revision. Defines stdio and the legacy HTTP+SSE
48 /// transport, and is the only revision where [`TransportKind::LegacySse`]
49 /// is defined at all.
50 ///
51 /// [`TransportKind::LegacySse`]: crate::domain::TransportKind::LegacySse
52 V2024_11_05,
53 /// Replaced the legacy HTTP+SSE transport with Streamable HTTP.
54 V2025_03_26,
55 /// Removed JSON-RPC batching and added structured tool output.
56 V2025_06_18,
57 /// Added URL-mode elicitation, icons, and experimental tasks.
58 V2025_11_25,
59 /// Made the protocol stateless: the `initialize` handshake is gone and every
60 /// request carries its version in `_meta`.
61 V2026_07_28,
62 /// A revision published after this build, identified by its release date.
63 ///
64 /// Only ever holds a date at or after [`ReleaseDate::V2024_11_05`], and in
65 /// practice only one newer than [`ProtocolVersion::V2026_07_28`] — see the
66 /// module docs.
67 Unknown(ReleaseDate),
68}
69
70impl ProtocolVersion {
71 /// Every revision this build has wire types for, oldest first.
72 ///
73 /// `draft` is not a separate version: upstream's floating `draft` schema
74 /// declares `LATEST_PROTOCOL_VERSION = "2026-07-28"`, the same as the final
75 /// revision, so it negotiates as that revision. The two differ in schema
76 /// shape, not in version string, and T08 keys the codec off the version.
77 pub const KNOWN: [Self; 5] = [
78 Self::V2024_11_05,
79 Self::V2025_03_26,
80 Self::V2025_06_18,
81 Self::V2025_11_25,
82 Self::V2026_07_28,
83 ];
84
85 /// The newest revision this build knows.
86 #[must_use]
87 pub const fn latest_known() -> Self {
88 Self::V2026_07_28
89 }
90
91 /// The oldest revision this build supports.
92 #[must_use]
93 pub const fn earliest_known() -> Self {
94 Self::V2024_11_05
95 }
96
97 /// The revision's release date.
98 #[must_use]
99 pub const fn date(self) -> ReleaseDate {
100 match self {
101 Self::V2024_11_05 => ReleaseDate::V2024_11_05,
102 Self::V2025_03_26 => ReleaseDate::V2025_03_26,
103 Self::V2025_06_18 => ReleaseDate::V2025_06_18,
104 Self::V2025_11_25 => ReleaseDate::V2025_11_25,
105 Self::V2026_07_28 => ReleaseDate::V2026_07_28,
106 Self::Unknown(date) => date,
107 }
108 }
109
110 /// The string this revision is negotiated as, borrowed when it is a known
111 /// one.
112 ///
113 /// `Cow` rather than `&'static str` because [`Unknown`] has no static
114 /// spelling; the six known revisions, which is what a codec asks about on
115 /// the hot path, allocate nothing.
116 ///
117 /// [`Unknown`]: Self::Unknown
118 #[must_use]
119 pub fn as_wire(self) -> std::borrow::Cow<'static, str> {
120 match self {
121 Self::V2024_11_05 => "2024-11-05".into(),
122 Self::V2025_03_26 => "2025-03-26".into(),
123 Self::V2025_06_18 => "2025-06-18".into(),
124 Self::V2025_11_25 => "2025-11-25".into(),
125 Self::V2026_07_28 => "2026-07-28".into(),
126 Self::Unknown(date) => date.to_string().into(),
127 }
128 }
129
130 /// Whether this build has wire types for the revision.
131 #[must_use]
132 pub const fn is_known(self) -> bool {
133 !matches!(self, Self::Unknown(_))
134 }
135
136 /// Whether this revision was published after the newest one this build
137 /// knows.
138 ///
139 /// Not `const`: the comparison comes from a derived `Ord`, and a derived
140 /// `Ord` is not a const-callable operator.
141 #[must_use]
142 pub fn is_newer_than_all_known(self) -> bool {
143 self > Self::latest_known()
144 }
145
146 /// Builds an `Unknown` from a date, refusing one that would break the
147 /// ordering invariant.
148 ///
149 /// The public enum variant means this cannot be the only way in, so the
150 /// function is not private; making it the obvious one is what keeps the
151 /// invariant from being broken by accident.
152 ///
153 /// # Errors
154 ///
155 /// Returns [`UnknownVersionError::NotAfterKnown`] when `date` is at or
156 /// before [`Self::latest_known`] — a date this build should have a variant
157 /// for, and [`FromStr`] would have matched it exactly.
158 ///
159 /// # Examples
160 ///
161 /// ```
162 /// use protocol_droid::domain::{ProtocolVersion, ReleaseDate};
163 ///
164 /// let future = ProtocolVersion::unknown_after_known("2027-01-01".parse().unwrap())?;
165 /// assert!(future.is_newer_than_all_known());
166 /// # Ok::<(), protocol_droid::domain::UnknownVersionError>(())
167 /// ```
168 pub fn unknown_after_known(date: ReleaseDate) -> Result<Self, UnknownVersionError> {
169 if date <= Self::latest_known().date() {
170 return Err(UnknownVersionError::NotAfterKnown { date });
171 }
172 Ok(Self::Unknown(date))
173 }
174}
175
176impl fmt::Display for ProtocolVersion {
177 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178 formatter.write_str(&self.as_wire())
179 }
180}
181
182impl FromStr for ProtocolVersion {
183 type Err = ParseProtocolVersionError;
184
185 /// Parses a negotiated version string.
186 ///
187 /// Three outcomes, in order:
188 ///
189 /// 1. A known revision's string becomes that variant. Matched on the whole
190 /// string first, because that is the only way to reject `2025-03-26x`
191 /// without first having to date-parse it.
192 /// 2. A well-formed date this build has not heard of becomes
193 /// [`Unknown`](Self::Unknown), provided it is at or after the first
194 /// revision.
195 /// 3. Everything else is an error.
196 fn from_str(text: &str) -> Result<Self, Self::Err> {
197 match text {
198 "2024-11-05" => return Ok(Self::V2024_11_05),
199 "2025-03-26" => return Ok(Self::V2025_03_26),
200 "2025-06-18" => return Ok(Self::V2025_06_18),
201 "2025-11-25" => return Ok(Self::V2025_11_25),
202 "2026-07-28" => return Ok(Self::V2026_07_28),
203 _ => {}
204 }
205 let date = text.parse::<ReleaseDate>()?;
206 if !date.is_at_or_after_first_revision() {
207 return Err(ParseProtocolVersionError::ReleaseDate(
208 ParseReleaseDateError::BeforeFirstRevision { date },
209 ));
210 }
211 // Past this point `date` is strictly newer than every known revision, so
212 // the derived ordering stays chronological. `unknown_after_known` is
213 // the checked form of that claim and cannot fail here.
214 Self::unknown_after_known(date).map_err(ParseProtocolVersionError::UnknownVersion)
215 }
216}
217
218/// Why a string is not a [`ProtocolVersion`].
219#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
220#[non_exhaustive]
221pub enum ParseProtocolVersionError {
222 /// The string is not a well-formed release date.
223 #[error(transparent)]
224 ReleaseDate(#[from] ParseReleaseDateError),
225 /// The date is well formed but is one this build should have a variant for.
226 #[error("a date this build already knows cannot become an unknown revision: {0}")]
227 UnknownVersion(#[from] UnknownVersionError),
228}
229
230/// Why a date cannot become [`ProtocolVersion::Unknown`].
231#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
232#[non_exhaustive]
233pub enum UnknownVersionError {
234 /// The date is at or before the newest known revision, so `Unknown` would
235 /// sort at or below a known variant and break the ordering invariant.
236 #[error("{date} is not after 2026-07-28, so it cannot be an unknown revision")]
237 NotAfterKnown {
238 /// The date that was offered.
239 date: ReleaseDate,
240 },
241}
242
243#[cfg(test)]
244mod tests {
245 // Test code unwraps deliberately: the strict profile exists to keep
246 // production paths fallible, and a test that cannot unwrap cannot assert.
247 // The messages name the failing precondition, which is the useful part.
248 #![expect(
249 clippy::unwrap_used,
250 reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
251 )]
252
253 use super::{ParseProtocolVersionError, ProtocolVersion, ReleaseDate, UnknownVersionError};
254 use std::collections::BTreeSet;
255
256 /// Every version the domain can produce, plus one `Unknown` per known
257 /// revision so the exhaustive comparisons have something on both sides.
258 fn corpus() -> Vec<ProtocolVersion> {
259 let mut versions = ProtocolVersion::KNOWN.to_vec();
260 versions.push(ProtocolVersion::Unknown("2027-01-01".parse().unwrap()));
261 versions.push(ProtocolVersion::Unknown("2030-12-31".parse().unwrap()));
262 versions
263 }
264
265 /// The 1.98 trap: `derive(PartialOrd)` has a fast path that assumes a
266 /// hand-written `PartialOrd` agrees with a derived `Ord`. A pair that
267 /// disagrees silently yields wrong comparisons instead of a compile error.
268 /// Every ordered type in this crate derives both traits from the same
269 /// variant order and copies this test when it is added.
270 ///
271 /// Exhaustive over every ordered pair, because "agrees in both directions"
272 /// is a statement about the whole type and not about two samples of it.
273 #[test]
274 fn derived_ordering_agrees_in_both_directions() {
275 for left in corpus() {
276 for right in corpus() {
277 let total = left.cmp(&right);
278 assert_eq!(
279 left.partial_cmp(&right),
280 Some(total),
281 "cmp and partial_cmp disagree for {left} against {right}"
282 );
283 assert_eq!(left < right, total.is_lt());
284 assert_eq!(left > right, total.is_gt());
285 assert_eq!(left <= right, total.is_le());
286 assert_eq!(left >= right, total.is_ge());
287 }
288 }
289 }
290
291 /// The invariant the module docs exist for: if `Unknown` could hold an old
292 /// date, the derived ordering would silently become wrong. These are the
293 /// cases that would break.
294 #[test]
295 fn unknown_dates_sort_after_every_known_revision() {
296 let future = ProtocolVersion::Unknown("2027-01-01".parse().unwrap());
297 for known in ProtocolVersion::KNOWN {
298 assert!(
299 known < future,
300 "{known} must sort below an unknown revision"
301 );
302 assert!(
303 future > known,
304 "{known} must sort below an unknown revision"
305 );
306 }
307 }
308
309 #[test]
310 fn declaration_order_is_chronological() {
311 let sorted: BTreeSet<ProtocolVersion> = ProtocolVersion::KNOWN.into_iter().collect();
312 let ascending: Vec<ProtocolVersion> = sorted.into_iter().collect();
313 assert_eq!(ascending, ProtocolVersion::KNOWN.to_vec());
314 assert_eq!(
315 ProtocolVersion::earliest_known(),
316 ProtocolVersion::V2024_11_05
317 );
318 assert_eq!(
319 ProtocolVersion::latest_known(),
320 ProtocolVersion::V2026_07_28
321 );
322 }
323
324 #[test]
325 fn parses_each_known_revision() {
326 let cases = [
327 ("2024-11-05", ProtocolVersion::V2024_11_05),
328 ("2025-03-26", ProtocolVersion::V2025_03_26),
329 ("2025-06-18", ProtocolVersion::V2025_06_18),
330 ("2025-11-25", ProtocolVersion::V2025_11_25),
331 ("2026-07-28", ProtocolVersion::V2026_07_28),
332 ];
333 for (text, expected) in cases {
334 assert_eq!(text.parse::<ProtocolVersion>(), Ok(expected), "{text}");
335 }
336 }
337
338 /// The forward-compatibility case, which is the reason `Unknown` exists.
339 #[test]
340 fn accepts_an_unknown_future_date() {
341 let parsed = "2027-03-14".parse::<ProtocolVersion>().unwrap();
342 assert_eq!(
343 parsed,
344 ProtocolVersion::Unknown("2027-03-14".parse::<ReleaseDate>().unwrap())
345 );
346 assert!(!parsed.is_known());
347 assert!(parsed.is_newer_than_all_known());
348 assert_eq!(parsed.as_wire(), "2027-03-14");
349 }
350
351 /// A well-formed date from before MCP existed is a different failure from
352 /// a malformed one, and gets its own error variant.
353 #[test]
354 fn rejects_a_date_from_before_the_first_revision() {
355 let error = "2020-01-01".parse::<ProtocolVersion>().unwrap_err();
356 assert!(matches!(error, ParseProtocolVersionError::ReleaseDate(_)));
357 assert!(error.to_string().contains("predates the first revision"));
358 }
359
360 #[test]
361 fn rejects_malformed_strings() {
362 for text in ["", "latest", "2025-3-26", "2025-03-26 ", " DRAFT-2025-v3"] {
363 assert!(text.parse::<ProtocolVersion>().is_err(), "{text:?} parsed");
364 }
365 }
366
367 /// Upstream's `2025-11-25` schema.ts declares
368 /// `LATEST_PROTOCOL_VERSION = "DRAFT-2025-v3"` while its own tag is
369 /// `2025-11-25` and every other revision's schema.ts carries its own date.
370 /// The tag is what the protocol documents, so the date is what this accepts;
371 /// the string is recorded in the README and in PROGRESS.md rather than
372 /// being quietly accepted, because accepting it would make the bridge claim
373 /// to speak a version the specification does not define.
374 #[test]
375 fn the_draft_string_upstream_ships_is_not_a_version() {
376 assert!("DRAFT-2025-v3".parse::<ProtocolVersion>().is_err());
377 }
378
379 #[test]
380 fn constructing_an_unknown_requires_a_later_date() {
381 let unknown = ProtocolVersion::unknown_after_known;
382 let error = unknown(ReleaseDate::V2025_06_18).unwrap_err();
383 assert_eq!(
384 error,
385 UnknownVersionError::NotAfterKnown {
386 date: ReleaseDate::V2025_06_18
387 }
388 );
389 // Exactly the latest known revision is refused too, not just older ones.
390 assert!(unknown(ReleaseDate::V2026_07_28).is_err());
391 assert!(unknown("2026-07-29".parse().unwrap()).is_ok());
392 }
393
394 #[test]
395 fn known_versions_never_report_as_unknown() {
396 for version in ProtocolVersion::KNOWN {
397 assert!(version.is_known());
398 assert!(!version.is_newer_than_all_known());
399 assert!(version <= ProtocolVersion::latest_known());
400 }
401 }
402
403 #[test]
404 fn round_trips_through_its_display_form() {
405 for version in corpus() {
406 assert_eq!(version.to_string().parse::<ProtocolVersion>(), Ok(version));
407 }
408 }
409
410 /// The wire string is borrowed for known revisions and owned for unknown
411 /// ones; both spellings are the same text, and the six known ones are the
412 /// ones a codec asks about on the hot path.
413 #[test]
414 fn as_wire_borrows_when_it_can() {
415 for version in ProtocolVersion::KNOWN {
416 assert!(matches!(version.as_wire(), std::borrow::Cow::Borrowed(_)));
417 assert_eq!(version.as_wire(), version.to_string());
418 }
419 let future = ProtocolVersion::Unknown("2027-03-14".parse().unwrap());
420 assert!(matches!(future.as_wire(), std::borrow::Cow::Owned(_)));
421 assert_eq!(future.as_wire(), "2027-03-14");
422 }
423
424 #[test]
425 fn dates_match_the_variants() {
426 assert_eq!(
427 ProtocolVersion::V2024_11_05.date(),
428 ReleaseDate::V2024_11_05
429 );
430 assert_eq!(
431 ProtocolVersion::V2025_03_26.date(),
432 ReleaseDate::V2025_03_26
433 );
434 assert_eq!(
435 ProtocolVersion::V2025_06_18.date(),
436 ReleaseDate::V2025_06_18
437 );
438 assert_eq!(
439 ProtocolVersion::V2025_11_25.date(),
440 ReleaseDate::V2025_11_25
441 );
442 assert_eq!(
443 ProtocolVersion::V2026_07_28.date(),
444 ReleaseDate::V2026_07_28
445 );
446 }
447}