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}