Skip to main content

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}