mcp/version.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! MCP protocol version + **era** model, and version negotiation for both eras.
3//!
4//! MCP versions are `YYYY-MM-DD` date strings marking the last
5//! backward-incompatible change. They sort chronologically as plain strings,
6//! which is what lets a lexical `>=` decide the era. Two eras exist:
7//!
8//! * **Legacy** — an `initialize` handshake + session (`2025-11-25` and earlier).
9//! The client advertises its latest version in `initialize`; the server echoes
10//! it if supported, else returns one it does (client adopts or disconnects).
11//! * **Modern** — stateless, per-request `_meta` (`2026-07-28`+). There is *no
12//! handshake*: every request declares its version, and an unsupported version is
13//! rejected per request with an [`UnsupportedProtocolVersion`] error (`-32022`)
14//! listing the server's `supported` versions; the client retries with a mutual
15//! one. A dual-era client detects the server's era once and caches it.
16
17use serde::Deserialize;
18
19/// A protocol era: how version/identity/capabilities are conveyed and whether the
20/// connection is session-based.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Era {
23 /// `initialize` handshake + session (`2025-11-25` and earlier).
24 Legacy,
25 /// Stateless per-request metadata (`2026-07-28` and later).
26 Modern,
27}
28
29/// Every MCP revision this library understands, **newest first** (dates sort
30/// chronologically). To support a newly-released revision, add its date at the
31/// front. The head is the latest overall; era-specific latests are
32/// [`LATEST_MODERN_VERSION`] / [`LATEST_LEGACY_VERSION`].
33pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[
34 "2026-07-28", // modern (stateless) — the RC (blog.modelcontextprotocol.io)
35 "2025-11-25", // legacy — the current stable
36 "2025-06-18",
37 "2025-03-26",
38 "2024-11-05",
39];
40
41/// The first **modern** (stateless) revision — the era boundary. Any well-formed
42/// date `>=` this is Modern; anything earlier is Legacy.
43pub const FIRST_MODERN_VERSION: &str = "2026-07-28";
44
45/// The latest modern revision (advertised where the peer is known to be modern).
46pub const LATEST_MODERN_VERSION: &str = "2026-07-28";
47
48/// The latest legacy revision — what the `initialize` handshake advertises.
49pub const LATEST_LEGACY_VERSION: &str = "2025-11-25";
50
51/// The version advertised in a **legacy** `initialize` handshake — necessarily a
52/// legacy revision, since a modern server has no handshake to advertise into. A
53/// modern peer is told the version per request instead
54/// ([`LATEST_MODERN_VERSION`]).
55pub const PROTOCOL_VERSION: &str = LATEST_LEGACY_VERSION;
56
57/// The version a legacy Streamable HTTP server assumes when a request carries no
58/// `MCP-Protocol-Version` header. A header-less request is therefore not an
59/// error — it is a request for this revision.
60pub const DEFAULT_NEGOTIATED_VERSION: &str = "2025-03-26";
61
62/// The MCP-reserved JSON-RPC error code for an unsupported protocol version
63/// (`-32022`, modern negotiation).
64pub const UNSUPPORTED_PROTOCOL_VERSION_CODE: i64 = -32022;
65
66/// The MCP-reserved JSON-RPC error code for a Streamable-HTTP header/body mismatch
67/// or a missing/malformed required routing header (`-32020`).
68pub const HEADER_MISMATCH_CODE: i64 = -32020;
69
70/// The `_meta` key namespace carrying per-request protocol metadata in the modern
71/// era (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities}`).
72pub const META_NS: &str = "io.modelcontextprotocol/";
73
74/// Is `v` a revision this library explicitly understands?
75pub fn is_supported_version(v: &str) -> bool {
76 SUPPORTED_PROTOCOL_VERSIONS.contains(&v)
77}
78
79/// Does `s` have the MCP `YYYY-MM-DD` version shape? (Cheap structural check, not a
80/// calendar validation — enough to tell a date revision from a bogus string.)
81pub fn is_date_version(s: &str) -> bool {
82 let b = s.as_bytes();
83 b.len() == 10
84 && b[4] == b'-'
85 && b[7] == b'-'
86 && b.iter()
87 .enumerate()
88 .all(|(i, &c)| i == 4 || i == 7 || c.is_ascii_digit())
89}
90
91/// The [`Era`] a protocol version belongs to. A well-formed date `>=`
92/// [`FIRST_MODERN_VERSION`] (including unknown future dates) is [`Era::Modern`];
93/// anything else is [`Era::Legacy`] (the safe default — legacy is the older,
94/// wider-deployed behavior).
95pub fn era_of(version: &str) -> Era {
96 if is_date_version(version) && version >= FIRST_MODERN_VERSION {
97 Era::Modern
98 } else {
99 Era::Legacy
100 }
101}
102
103/// Negotiate the session version from a **legacy** server's `initialize`
104/// response. The server echoes our advertised version if it supports it, else
105/// returns another it supports; this decides whether that answer is usable.
106///
107/// * A version we **know** → adopt it.
108/// * An **unknown but newer** well-formed date → adopt it optimistically
109/// (forward-compat: a future revision keeps our stable method subset, so a
110/// brand-new server is reachable *before* we add its date above) — the caller
111/// should log that it is speaking an unrecognized revision.
112/// * Anything else (an older-unknown or malformed version) → `None`: the client
113/// cannot agree on a version and SHOULD disconnect.
114pub fn negotiate_version(server_version: &str) -> Option<String> {
115 if is_supported_version(server_version) {
116 return Some(server_version.to_string());
117 }
118 if is_date_version(server_version) && server_version > SUPPORTED_PROTOCOL_VERSIONS[0] {
119 return Some(server_version.to_string());
120 }
121 None
122}
123
124/// The payload of an [`UNSUPPORTED_PROTOCOL_VERSION_CODE`] error's `data` — the
125/// modern era's whole version-negotiation signal, since there is no handshake in
126/// which to agree a version up front.
127#[derive(Debug, Clone, Default, Deserialize)]
128pub struct UnsupportedProtocolVersion {
129 /// The versions the server supports.
130 #[serde(default)]
131 pub supported: Vec<String>,
132 /// The version the client requested (echoed back).
133 #[serde(default)]
134 pub requested: Option<String>,
135}
136
137/// Given a modern server's advertised `supported` versions (from a `-32022`
138/// error), pick the best mutually-supported one to retry with — our newest that
139/// the server also supports. `None` ⇒ no common version (surface to the user).
140pub fn best_mutual_version(server_supported: &[String]) -> Option<String> {
141 SUPPORTED_PROTOCOL_VERSIONS
142 .iter()
143 .find(|&&ours| server_supported.iter().any(|s| s == ours))
144 .map(|v| v.to_string())
145}
146
147/// Is `code` a JSON-RPC error code only a **modern** server emits? Used for era
148/// detection: a `-32022`
149/// (UnsupportedProtocolVersion) or `-32020` (HeaderMismatch) in the body of a
150/// failed modern probe identifies a modern server, so the client retries rather
151/// than falling back to `initialize`. Generic codes (e.g. `-32601` method-not-
152/// found) are ambiguous across eras and are NOT modern-defining.
153pub fn is_modern_error_code(code: i64) -> bool {
154 code == UNSUPPORTED_PROTOCOL_VERSION_CODE || code == HEADER_MISMATCH_CODE
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn eras_split_at_the_2026_boundary() {
163 assert_eq!(era_of("2024-11-05"), Era::Legacy);
164 assert_eq!(era_of("2025-11-25"), Era::Legacy);
165 assert_eq!(era_of("2026-07-28"), Era::Modern);
166 // An unknown FUTURE date is modern (forward-compat); a malformed string is
167 // treated as legacy (the safe, wider-deployed default).
168 assert_eq!(era_of("2099-01-01"), Era::Modern);
169 assert_eq!(era_of("1.0.0"), Era::Legacy);
170 }
171
172 #[test]
173 fn era_latests_are_consistent() {
174 assert_eq!(era_of(LATEST_LEGACY_VERSION), Era::Legacy);
175 assert_eq!(era_of(LATEST_MODERN_VERSION), Era::Modern);
176 assert_eq!(FIRST_MODERN_VERSION, LATEST_MODERN_VERSION);
177 assert!(is_supported_version(LATEST_LEGACY_VERSION));
178 assert!(is_supported_version(LATEST_MODERN_VERSION));
179 // The list is newest-first.
180 let mut sorted = SUPPORTED_PROTOCOL_VERSIONS.to_vec();
181 sorted.sort_unstable();
182 sorted.reverse();
183 assert_eq!(sorted.as_slice(), SUPPORTED_PROTOCOL_VERSIONS);
184 }
185
186 #[test]
187 fn is_date_version_recognizes_the_shape() {
188 assert!(is_date_version("2025-11-25"));
189 assert!(is_date_version("2026-07-28"));
190 assert!(!is_date_version("2025-11-5"));
191 assert!(!is_date_version("2025/11/25"));
192 assert!(!is_date_version("1.0.0"));
193 }
194
195 #[test]
196 fn legacy_negotiate_adopts_known_and_newer_but_refuses_old_unknown() {
197 for v in SUPPORTED_PROTOCOL_VERSIONS {
198 assert_eq!(negotiate_version(v).as_deref(), Some(*v));
199 }
200 assert_eq!(
201 negotiate_version("2099-01-01").as_deref(),
202 Some("2099-01-01")
203 );
204 assert_eq!(negotiate_version("2020-01-01"), None);
205 assert_eq!(negotiate_version("1.0.0"), None);
206 }
207
208 #[test]
209 fn modern_best_mutual_picks_our_newest_common() {
210 // Server supports two versions; we pick our newest that overlaps.
211 let supported = vec!["2025-11-25".to_string(), "2026-07-28".to_string()];
212 assert_eq!(
213 best_mutual_version(&supported).as_deref(),
214 Some("2026-07-28")
215 );
216 // Only an older overlap.
217 let supported = vec!["2025-06-18".to_string()];
218 assert_eq!(
219 best_mutual_version(&supported).as_deref(),
220 Some("2025-06-18")
221 );
222 // No overlap at all.
223 let supported = vec!["1900-01-01".to_string()];
224 assert_eq!(best_mutual_version(&supported), None);
225 }
226
227 #[test]
228 fn modern_error_codes_are_recognized() {
229 assert!(is_modern_error_code(UNSUPPORTED_PROTOCOL_VERSION_CODE));
230 assert!(is_modern_error_code(HEADER_MISMATCH_CODE));
231 // Generic JSON-RPC codes are ambiguous across eras, not modern-defining.
232 assert!(!is_modern_error_code(-32601)); // method not found
233 assert!(!is_modern_error_code(-32602)); // invalid params
234 assert!(!is_modern_error_code(-32000));
235 }
236
237 #[test]
238 fn unsupported_error_payload_parses() {
239 let data = serde_json::json!({
240 "supported": ["2026-07-28", "2025-11-25"],
241 "requested": "1900-01-01"
242 });
243 let p: UnsupportedProtocolVersion = serde_json::from_value(data).unwrap();
244 assert_eq!(p.supported, ["2026-07-28", "2025-11-25"]);
245 assert_eq!(p.requested.as_deref(), Some("1900-01-01"));
246 }
247}