Skip to main content

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}