Skip to main content

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}