Skip to main content

pmcp/types/protocol/
version.rs

1//! Protocol version constants and negotiation logic.
2
3/// Latest protocol version supported by this SDK.
4pub const LATEST_PROTOCOL_VERSION: &str = "2025-11-25";
5
6/// Default protocol version used for negotiation fallback.
7pub const DEFAULT_PROTOCOL_VERSION: &str = "2025-03-26";
8
9/// All protocol versions supported by this SDK.
10///
11/// Includes the 2024-11-05 base version for backward compatibility with
12/// clients that haven't upgraded yet (Claude Code, Cursor, etc.).
13/// The 2025 versions add features but the base JSON-RPC request/response
14/// format is the same — accepting 2024-11-05 is safe.
15pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[
16    LATEST_PROTOCOL_VERSION,
17    "2025-06-18",
18    DEFAULT_PROTOCOL_VERSION,
19    "2024-11-05",
20];
21
22/// The MCP 2026-07-28 (v2) protocol version, opt-in only.
23///
24/// This constant is **deliberately not** a member of
25/// [`SUPPORTED_PROTOCOL_VERSIONS`] and is **never** returned by
26/// [`negotiate_protocol_version`]. The v2 era is reached only through the
27/// per-server opt-in accept-list (Phase 112 Plan 04), never through legacy
28/// version negotiation. Keeping [`LATEST_PROTOCOL_VERSION`] pinned to
29/// `2025-11-25` is the single most important backward-compat guard in the
30/// v2.5 milestone: `negotiate_protocol_version` returns `LATEST` for any
31/// unknown version, so flipping `LATEST` would silently upgrade legacy
32/// clients to v2 semantics.
33pub const PROTOCOL_VERSION_2026_07_28: &str = "2026-07-28";
34
35/// Every v2-generation protocol version this SDK knows, in the SDK's own
36/// spelling.
37///
38/// THE authority for v2 membership. [`protocol_era`] classifies against this
39/// table and [`known_protocol_version`] echoes the entry it matched, so the
40/// classifier and the header echo cannot disagree and a matched version is
41/// always echoed as ITSELF rather than as whichever constant happened to be
42/// hardcoded. Adding a second v2-generation version is therefore ONE edit here:
43/// it becomes era-`V2`, client-selectable and server-echoable in the same
44/// breath. A constant added WITHOUT being listed here reaches none of the
45/// three — the fail-closed direction — rather than being silently echoed under
46/// another version's spelling.
47///
48/// Deliberately NOT merged into [`SUPPORTED_PROTOCOL_VERSIONS`]: that table is
49/// what [`negotiate_protocol_version`] falls back through, and v2 is reachable
50/// only by explicit opt-in.
51pub(crate) const V2_PROTOCOL_VERSIONS: &[&str] = &[PROTOCOL_VERSION_2026_07_28];
52
53/// Protocol era: the coarse behavioral generation a negotiated version belongs to.
54///
55/// The whole v2.5 milestone era-gates off this classifier. `V1` covers every
56/// `2024`/`2025` protocol version (the compatibility layer); `V2` is the
57/// `2026-07-28` stateless/Tasks/MCP-Apps generation. Unknown or unrecognized
58/// versions conservatively classify as [`Era::V1`] so a malformed or
59/// forward-dated version string can never accidentally reach v2 behavior.
60///
61/// # Why this derives `Hash`
62///
63/// The emit-time `outputSchema` validator cache in
64/// `crate::server::output_validation` is keyed on `(Era, schema text)`, not on
65/// the schema text alone: under Phase 115 D-01 the SAME schema document
66/// compiles to two DIFFERENT validators depending on the era (v1 auto-detects
67/// the declared `$schema` dialect; v2 pins Draft 2020-12). Keying on text alone
68/// would be first-writer-wins for the process lifetime — whichever era compiled
69/// a given schema first would serve its validator to the other. `Hash` on this
70/// fieldless enum is what makes the tuple key possible.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum Era {
73    /// The 2024/2025 protocol generation (compatibility layer, current default).
74    V1,
75    /// The 2026-07-28 protocol generation (opt-in, stateless-first).
76    V2,
77}
78
79/// Classify a negotiated protocol version string into its behavioral [`Era`].
80///
81/// Returns [`Era::V2`] **only** for [`PROTOCOL_VERSION_2026_07_28`]
82/// (`"2026-07-28"`). Every other string — including all supported v1 versions
83/// and any unknown/malformed input — classifies as [`Era::V1`]. This
84/// conservative unknown-to-`V1` fallback guarantees that only an exact,
85/// deliberate `2026-07-28` negotiation reaches v2 behavior.
86///
87/// ```
88/// use pmcp::types::protocol::{protocol_era, Era, PROTOCOL_VERSION_2026_07_28};
89///
90/// assert_eq!(protocol_era(PROTOCOL_VERSION_2026_07_28), Era::V2);
91/// assert_eq!(protocol_era("2025-11-25"), Era::V1);
92/// assert_eq!(protocol_era("who-knows"), Era::V1);
93/// ```
94pub fn protocol_era(version: &str) -> Era {
95    // Against the TABLE, not against one constant: the table is what
96    // `known_protocol_version` echoes from, so classifying off anything else
97    // would let the two disagree by construction.
98    if V2_PROTOCOL_VERSIONS.contains(&version) {
99        Era::V2
100    } else {
101        Era::V1
102    }
103}
104
105/// The SDK's own spelling of a protocol version it knows, or `None`.
106///
107/// THE single membership authority for "is this a version this SDK speaks".
108/// Two copies of that predicate existed before this was extracted — one in the
109/// client's opt-in validation and one in the server's outbound-header echo —
110/// and they were free to disagree by construction, which is precisely the
111/// silent-downgrade class the header echo exists to close.
112///
113/// Returns a `&'static str` from this module's own tables rather than the
114/// caller's bytes. That is what makes the answer safe to write into a RESPONSE
115/// header: it can carry nothing attacker-chosen and cannot fail the
116/// `HeaderValue` parse that every emission site unwraps.
117///
118/// Searches BOTH tables and returns the entry it matched, so the answer is
119/// always the queried version's own spelling. [`SUPPORTED_PROTOCOL_VERSIONS`]
120/// deliberately excludes the v2 generation (v2 is reachable only by explicit
121/// opt-in, never by the v1 negotiation fallback), which is why
122/// [`V2_PROTOCOL_VERSIONS`] is a second table rather than more rows in the
123/// first.
124///
125/// # When a SECOND v2-generation constant is added
126///
127/// Add it to [`V2_PROTOCOL_VERSIONS`] and nothing here changes: it becomes
128/// era-`V2` (that table IS what [`protocol_era`] classifies against),
129/// client-selectable, and echoed under its OWN spelling. An earlier shape of
130/// this function returned a hardcoded [`PROTOCOL_VERSION_2026_07_28`] for
131/// anything the classifier called [`Era::V2`], which would have echoed the
132/// wrong spelling for a second version — and no test could have caught it,
133/// because the wrong spelling only appears once the second constant exists.
134/// Matching the table removes the failure mode instead of watching for it.
135pub(crate) fn known_protocol_version(version: &str) -> Option<&'static str> {
136    SUPPORTED_PROTOCOL_VERSIONS
137        .iter()
138        .chain(V2_PROTOCOL_VERSIONS)
139        .find(|known| **known == version)
140        .copied()
141}
142
143/// Negotiate the protocol version for an MCP session.
144///
145/// If the client's requested version is in [`SUPPORTED_PROTOCOL_VERSIONS`],
146/// echo it back (highest common version). Otherwise return
147/// [`LATEST_PROTOCOL_VERSION`] -- the caller should treat this as
148/// "unsupported version" and may return a JSON-RPC error with the
149/// supported versions list.
150pub fn negotiate_protocol_version(client_version: &str) -> &str {
151    if SUPPORTED_PROTOCOL_VERSIONS.contains(&client_version) {
152        client_version
153    } else {
154        LATEST_PROTOCOL_VERSION
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn latest_version_is_2025_11_25() {
164        assert_eq!(LATEST_PROTOCOL_VERSION, "2025-11-25");
165    }
166
167    #[test]
168    fn supports_four_versions_including_2024() {
169        assert_eq!(SUPPORTED_PROTOCOL_VERSIONS.len(), 4);
170        assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&"2025-11-25"));
171        assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&"2025-06-18"));
172        assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&"2025-03-26"));
173        assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&"2024-11-05"));
174    }
175
176    #[test]
177    fn rejects_unknown_2024_versions() {
178        // 2024-10-07 was never a real MCP version
179        assert!(!SUPPORTED_PROTOCOL_VERSIONS.contains(&"2024-10-07"));
180    }
181
182    #[test]
183    fn negotiate_supported_version_echoes_back() {
184        assert_eq!(negotiate_protocol_version("2025-11-25"), "2025-11-25");
185        assert_eq!(negotiate_protocol_version("2025-06-18"), "2025-06-18");
186        assert_eq!(negotiate_protocol_version("2025-03-26"), "2025-03-26");
187        assert_eq!(negotiate_protocol_version("2024-11-05"), "2024-11-05");
188    }
189
190    #[test]
191    fn negotiate_unsupported_returns_latest() {
192        assert_eq!(negotiate_protocol_version("2024-10-07"), "2025-11-25");
193        assert_eq!(negotiate_protocol_version("unknown"), "2025-11-25");
194    }
195
196    #[test]
197    fn v2_constant_is_not_in_legacy_supported_set() {
198        // 2026-07-28 (v2) is opt-in only — it must NEVER be a member of the
199        // legacy-negotiation set. This guards the legacy-negotiation set (the
200        // versions reachable via `negotiate_protocol_version`), NOT "every
201        // version the crate can understand". v2 is reached only via the
202        // opt-in accept-list (Plan 04), never legacy negotiation (Pitfall 1).
203        assert_eq!(PROTOCOL_VERSION_2026_07_28, "2026-07-28");
204        assert!(!SUPPORTED_PROTOCOL_VERSIONS.contains(&PROTOCOL_VERSION_2026_07_28));
205    }
206
207    #[test]
208    fn negotiate_never_upgrades_legacy_client_to_v2() {
209        // A legacy client asking for an unknown version must fall back to
210        // LATEST (v1), never to the v2 constant.
211        assert_ne!(
212            negotiate_protocol_version("2026-07-28"),
213            PROTOCOL_VERSION_2026_07_28
214        );
215        assert_eq!(
216            negotiate_protocol_version("2026-07-28"),
217            LATEST_PROTOCOL_VERSION
218        );
219    }
220
221    #[test]
222    fn protocol_era_classifies_2026_07_28_as_v2() {
223        assert_eq!(protocol_era("2026-07-28"), Era::V2);
224        assert_eq!(protocol_era(PROTOCOL_VERSION_2026_07_28), Era::V2);
225    }
226
227    #[test]
228    fn protocol_era_classifies_known_v1_versions_as_v1() {
229        assert_eq!(protocol_era("2025-11-25"), Era::V1);
230        assert_eq!(protocol_era("2025-06-18"), Era::V1);
231        assert_eq!(protocol_era("2025-03-26"), Era::V1);
232        assert_eq!(protocol_era("2024-11-05"), Era::V1);
233    }
234
235    #[test]
236    fn protocol_era_classifies_unknown_as_v1() {
237        // Conservative unknown -> V1 fallback: malformed/forward-dated strings
238        // must never accidentally reach v2 behavior.
239        assert_eq!(protocol_era("unknown"), Era::V1);
240        assert_eq!(protocol_era(""), Era::V1);
241        assert_eq!(protocol_era("2027-01-01"), Era::V1);
242        assert_eq!(protocol_era("2026-07-29"), Era::V1);
243    }
244
245    /// Membership, the era classifier and the echo all read the same two
246    /// tables — and the echo returns the row it MATCHED.
247    ///
248    /// DERIVED over the tables rather than over a hand-written id list, so a
249    /// version added to either one is in scope here with no edit. That is what
250    /// an earlier shape of this fence could not do: it named
251    /// `PROTOCOL_VERSION_2026_07_28` explicitly and asserted membership only
252    /// for four hardcoded non-versions, so a second v2-generation constant —
253    /// the exact drift `known_protocol_version`'s rustdoc said it guarded — was
254    /// invisible to it. Looping the table closes that, and matching from the
255    /// table (rather than returning a hardcoded constant) removes the failure
256    /// mode the loop would have had to watch for.
257    #[test]
258    fn known_protocol_version_agrees_with_the_era_classifier() {
259        // Every version either table admits must round-trip to ITSELF. An echo
260        // that answered with another version's spelling would advertise the
261        // wrong protocol to a client that asserted the new one.
262        for version in SUPPORTED_PROTOCOL_VERSIONS
263            .iter()
264            .chain(V2_PROTOCOL_VERSIONS)
265        {
266            assert_eq!(
267                known_protocol_version(version),
268                Some(*version),
269                "{version} must map to its own spelling, not another"
270            );
271        }
272
273        // The v1 table and the v2 table must not overlap, and each must land in
274        // the era its table names — `SUPPORTED_PROTOCOL_VERSIONS` is what the v1
275        // negotiation fallback walks, so a v2 entry appearing there would make
276        // v2 reachable without opt-in.
277        for version in SUPPORTED_PROTOCOL_VERSIONS {
278            assert_eq!(
279                protocol_era(version),
280                Era::V1,
281                "{version} is in the v1 negotiation table and must classify as V1"
282            );
283        }
284        for version in V2_PROTOCOL_VERSIONS {
285            assert_eq!(
286                protocol_era(version),
287                Era::V2,
288                "{version} is in the v2 table and must classify as V2"
289            );
290            assert!(
291                !SUPPORTED_PROTOCOL_VERSIONS.contains(version),
292                "{version} must not also sit in the v1 negotiation table, or v2 becomes reachable \
293                 through the fallback instead of by explicit opt-in"
294            );
295        }
296
297        // And a version in neither table is known to nobody.
298        for candidate in ["", "2027-01-01", "2026-07-29", "not-a-version"] {
299            assert_eq!(known_protocol_version(candidate), None);
300            assert_eq!(protocol_era(candidate), Era::V1);
301        }
302    }
303}