mcp/version.rs
1// SPDX-License-Identifier: Apache-2.0
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 backward-incompatible
5//! change; they sort chronologically as plain strings. Two eras
6//! (modelcontextprotocol.io/specification/draft/basic/versioning §terminology):
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. Kept as the
52/// latest legacy revision: the handshake path speaks legacy until the modern
53/// (stateless) dialect is wired into the client (a later phase). A modern client
54/// declares its version per-request instead ([`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 (transports §protocol-version-header).
59pub const DEFAULT_NEGOTIATED_VERSION: &str = "2025-03-26";
60
61/// The MCP-reserved JSON-RPC error code for an unsupported protocol version
62/// (`-32022`, modern negotiation).
63pub const UNSUPPORTED_PROTOCOL_VERSION_CODE: i64 = -32022;
64
65/// The MCP-reserved JSON-RPC error code for a Streamable-HTTP header/body mismatch
66/// or a missing/malformed required routing header (`-32020`).
67pub const HEADER_MISMATCH_CODE: i64 = -32020;
68
69/// The `_meta` key namespace carrying per-request protocol metadata in the modern
70/// era (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities}`).
71pub const META_NS: &str = "io.modelcontextprotocol/";
72
73/// Is `v` a revision this library explicitly understands?
74pub fn is_supported_version(v: &str) -> bool {
75 SUPPORTED_PROTOCOL_VERSIONS.contains(&v)
76}
77
78/// Does `s` have the MCP `YYYY-MM-DD` version shape? (Cheap structural check, not a
79/// calendar validation — enough to tell a date revision from a bogus string.)
80pub fn is_date_version(s: &str) -> bool {
81 let b = s.as_bytes();
82 b.len() == 10
83 && b[4] == b'-'
84 && b[7] == b'-'
85 && b.iter()
86 .enumerate()
87 .all(|(i, &c)| i == 4 || i == 7 || c.is_ascii_digit())
88}
89
90/// The [`Era`] a protocol version belongs to. A well-formed date `>=`
91/// [`FIRST_MODERN_VERSION`] (including unknown future dates) is [`Era::Modern`];
92/// anything else is [`Era::Legacy`] (the safe default — legacy is the older,
93/// wider-deployed behavior).
94pub fn era_of(version: &str) -> Era {
95 if is_date_version(version) && version >= FIRST_MODERN_VERSION {
96 Era::Modern
97 } else {
98 Era::Legacy
99 }
100}
101
102/// Negotiate the session version from a **legacy** server's `initialize` response
103/// (lifecycle §version-negotiation). The server echoes our advertised version if
104/// it supports it, else returns another it supports.
105///
106/// * A version we **know** → adopt it.
107/// * An **unknown but newer** well-formed date → adopt it optimistically
108/// (forward-compat: a future revision keeps our stable method subset, so a
109/// brand-new server is reachable *before* we add its date above) — the caller
110/// should log that it is speaking an unrecognized revision.
111/// * Anything else (an older-unknown or malformed version) → `None`: the client
112/// cannot agree on a version and SHOULD disconnect.
113pub fn negotiate_version(server_version: &str) -> Option<String> {
114 if is_supported_version(server_version) {
115 return Some(server_version.to_string());
116 }
117 if is_date_version(server_version) && server_version > SUPPORTED_PROTOCOL_VERSIONS[0] {
118 return Some(server_version.to_string());
119 }
120 None
121}
122
123/// The payload of an [`UNSUPPORTED_PROTOCOL_VERSION_CODE`] error's `data` — the
124/// modern era's version-negotiation signal (versioning §protocol-version-negotiation).
125#[derive(Debug, Clone, Default, Deserialize)]
126pub struct UnsupportedProtocolVersion {
127 /// The versions the server supports.
128 #[serde(default)]
129 pub supported: Vec<String>,
130 /// The version the client requested (echoed back).
131 #[serde(default)]
132 pub requested: Option<String>,
133}
134
135/// Given a modern server's advertised `supported` versions (from a `-32022`
136/// error), pick the best mutually-supported one to retry with — our newest that
137/// the server also supports. `None` ⇒ no common version (surface to the user).
138pub fn best_mutual_version(server_supported: &[String]) -> Option<String> {
139 SUPPORTED_PROTOCOL_VERSIONS
140 .iter()
141 .find(|&&ours| server_supported.iter().any(|s| s == ours))
142 .map(|v| v.to_string())
143}
144
145/// Is `code` a JSON-RPC error code only a **modern** server emits? Used for era
146/// detection (versioning §backward-compatibility): a `-32022`
147/// (UnsupportedProtocolVersion) or `-32020` (HeaderMismatch) in the body of a
148/// failed modern probe identifies a modern server, so the client retries rather
149/// than falling back to `initialize`. Generic codes (e.g. `-32601` method-not-
150/// found) are ambiguous across eras and are NOT modern-defining.
151pub fn is_modern_error_code(code: i64) -> bool {
152 code == UNSUPPORTED_PROTOCOL_VERSION_CODE || code == HEADER_MISMATCH_CODE
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn eras_split_at_the_2026_boundary() {
161 assert_eq!(era_of("2024-11-05"), Era::Legacy);
162 assert_eq!(era_of("2025-11-25"), Era::Legacy);
163 assert_eq!(era_of("2026-07-28"), Era::Modern);
164 // An unknown FUTURE date is modern (forward-compat); a malformed string is
165 // treated as legacy (the safe, wider-deployed default).
166 assert_eq!(era_of("2099-01-01"), Era::Modern);
167 assert_eq!(era_of("1.0.0"), Era::Legacy);
168 }
169
170 #[test]
171 fn era_latests_are_consistent() {
172 assert_eq!(era_of(LATEST_LEGACY_VERSION), Era::Legacy);
173 assert_eq!(era_of(LATEST_MODERN_VERSION), Era::Modern);
174 assert_eq!(FIRST_MODERN_VERSION, LATEST_MODERN_VERSION);
175 assert!(is_supported_version(LATEST_LEGACY_VERSION));
176 assert!(is_supported_version(LATEST_MODERN_VERSION));
177 // The list is newest-first.
178 let mut sorted = SUPPORTED_PROTOCOL_VERSIONS.to_vec();
179 sorted.sort_unstable();
180 sorted.reverse();
181 assert_eq!(sorted.as_slice(), SUPPORTED_PROTOCOL_VERSIONS);
182 }
183
184 #[test]
185 fn is_date_version_recognizes_the_shape() {
186 assert!(is_date_version("2025-11-25"));
187 assert!(is_date_version("2026-07-28"));
188 assert!(!is_date_version("2025-11-5"));
189 assert!(!is_date_version("2025/11/25"));
190 assert!(!is_date_version("1.0.0"));
191 }
192
193 #[test]
194 fn legacy_negotiate_adopts_known_and_newer_but_refuses_old_unknown() {
195 for v in SUPPORTED_PROTOCOL_VERSIONS {
196 assert_eq!(negotiate_version(v).as_deref(), Some(*v));
197 }
198 assert_eq!(
199 negotiate_version("2099-01-01").as_deref(),
200 Some("2099-01-01")
201 );
202 assert_eq!(negotiate_version("2020-01-01"), None);
203 assert_eq!(negotiate_version("1.0.0"), None);
204 }
205
206 #[test]
207 fn modern_best_mutual_picks_our_newest_common() {
208 // Server supports two versions; we pick our newest that overlaps.
209 let supported = vec!["2025-11-25".to_string(), "2026-07-28".to_string()];
210 assert_eq!(
211 best_mutual_version(&supported).as_deref(),
212 Some("2026-07-28")
213 );
214 // Only an older overlap.
215 let supported = vec!["2025-06-18".to_string()];
216 assert_eq!(
217 best_mutual_version(&supported).as_deref(),
218 Some("2025-06-18")
219 );
220 // No overlap at all.
221 let supported = vec!["1900-01-01".to_string()];
222 assert_eq!(best_mutual_version(&supported), None);
223 }
224
225 #[test]
226 fn modern_error_codes_are_recognized() {
227 assert!(is_modern_error_code(UNSUPPORTED_PROTOCOL_VERSION_CODE));
228 assert!(is_modern_error_code(HEADER_MISMATCH_CODE));
229 // Generic JSON-RPC codes are ambiguous across eras, not modern-defining.
230 assert!(!is_modern_error_code(-32601)); // method not found
231 assert!(!is_modern_error_code(-32602)); // invalid params
232 assert!(!is_modern_error_code(-32000));
233 }
234
235 #[test]
236 fn unsupported_error_payload_parses() {
237 let data = serde_json::json!({
238 "supported": ["2026-07-28", "2025-11-25"],
239 "requested": "1900-01-01"
240 });
241 let p: UnsupportedProtocolVersion = serde_json::from_value(data).unwrap();
242 assert_eq!(p.supported, ["2026-07-28", "2025-11-25"]);
243 assert_eq!(p.requested.as_deref(), Some("1900-01-01"));
244 }
245}