use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum McpVersion {
V2025_11_25,
V2026_07_28,
}
impl McpVersion {
pub fn as_str(&self) -> &'static str {
match self {
McpVersion::V2025_11_25 => "2025-11-25",
McpVersion::V2026_07_28 => "2026-07-28",
}
}
}
impl std::fmt::Display for McpVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DiscoverProbe {
Discovered,
JsonRpcError(i64, Option<Vec<String>>),
HttpStatus(u16),
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ProbeDecision {
Use2026,
FallbackTo2025,
Abort(String),
}
const METHOD_NOT_FOUND: i64 = -32601;
const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
const INVALID_PARAMS: i64 = -32602;
const HEADER_MISMATCH: i64 = -32020;
const MISSING_REQUIRED_CLIENT_CAPABILITY: i64 = -32021;
#[allow(dead_code)]
pub(crate) fn classify_probe(
probe: DiscoverProbe,
allow_legacy_gateway_fallback: bool,
) -> ProbeDecision {
match probe {
DiscoverProbe::Discovered => ProbeDecision::Use2026,
DiscoverProbe::JsonRpcError(METHOD_NOT_FOUND, _) => ProbeDecision::FallbackTo2025,
DiscoverProbe::JsonRpcError(INVALID_PARAMS, _) => ProbeDecision::FallbackTo2025,
DiscoverProbe::JsonRpcError(UNSUPPORTED_PROTOCOL_VERSION, supported) => match supported {
Some(list) if list.iter().any(|v| v == "2025-11-25") => ProbeDecision::FallbackTo2025,
Some(list) => ProbeDecision::Abort(format!(
"server declined 2026-07-28 and its supported versions {list:?} \
include no version this client speaks (2026-07-28, 2025-11-25)"
)),
None => ProbeDecision::Abort(
"server declined 2026-07-28 (UnsupportedProtocolVersionError) but named no \
supported-version list to select from — not a downgrade trigger"
.to_string(),
),
},
DiscoverProbe::JsonRpcError(code @ HEADER_MISMATCH, _)
| DiscoverProbe::JsonRpcError(code @ MISSING_REQUIRED_CLIENT_CAPABILITY, _) => {
ProbeDecision::Abort(format!(
"server/discover rejected with a recognized modern-server error {code}; \
the server speaks 2026-07-28 — not a downgrade trigger"
))
}
DiscoverProbe::JsonRpcError(code, _) => ProbeDecision::Abort(format!(
"server/discover rejected with JSON-RPC error {code}; the server \
understood the method and refused it — not a version signal, not a downgrade trigger"
)),
DiscoverProbe::HttpStatus(status)
if allow_legacy_gateway_fallback && (status == 404 || status == 405) =>
{
ProbeDecision::FallbackTo2025
}
DiscoverProbe::HttpStatus(status) => ProbeDecision::Abort(format!(
"server/discover failed with HTTP {status}; transport or authorization \
failure, not a version signal (no silent downgrade)"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_wire_strings() {
assert_eq!(McpVersion::V2025_11_25.as_str(), "2025-11-25");
assert_eq!(McpVersion::V2026_07_28.as_str(), "2026-07-28");
}
#[test]
fn discover_ok_locks_2026() {
assert_eq!(
classify_probe(DiscoverProbe::Discovered, false),
ProbeDecision::Use2026
);
}
#[test]
fn unsupported_protocol_version_falls_back_to_2025() {
assert_eq!(
classify_probe(
DiscoverProbe::JsonRpcError(
-32022,
Some(vec!["2025-11-25".to_string(), "2025-06-18".to_string()])
),
false
),
ProbeDecision::FallbackTo2025
);
assert!(matches!(
classify_probe(
DiscoverProbe::JsonRpcError(-32022, Some(vec!["2099-01-01".to_string()])),
false
),
ProbeDecision::Abort(msg) if msg.contains("2099-01-01")
));
}
#[test]
fn unsupported_protocol_version_with_no_list_aborts_without_downgrade() {
assert!(
matches!(
classify_probe(DiscoverProbe::JsonRpcError(-32022, None), false),
ProbeDecision::Abort(_)
),
"-32022 with no supported list must abort, not silently fall back to 2025-11-25"
);
}
#[test]
fn pre_renumbering_32004_is_unrecognized_and_aborts() {
assert!(
matches!(
classify_probe(DiscoverProbe::JsonRpcError(-32004, None), false),
ProbeDecision::Abort(_)
),
"-32004 must be treated as an unrecognized error, not the UnsupportedProtocolVersionError signal"
);
}
#[test]
fn recognized_modern_server_errors_abort_without_downgrade() {
for code in [-32020, -32021] {
assert!(
matches!(
classify_probe(DiscoverProbe::JsonRpcError(code, None), false),
ProbeDecision::Abort(_)
),
"JSON-RPC error {code} is a recognized modern-server signal and must abort, not downgrade"
);
}
}
#[test]
fn legacy_error_codes_fall_back_to_2025() {
for code in [-32601, -32602] {
assert_eq!(
classify_probe(DiscoverProbe::JsonRpcError(code, None), false),
ProbeDecision::FallbackTo2025,
"JSON-RPC error {code} is a recognized legacy-server signal"
);
}
}
#[test]
fn other_jsonrpc_errors_abort_without_downgrade() {
for code in [-32700, -32600, -32603, -32002, 100] {
assert!(
matches!(
classify_probe(DiscoverProbe::JsonRpcError(code, None), false),
ProbeDecision::Abort(_)
),
"JSON-RPC error {code} must abort, not downgrade"
);
}
}
#[test]
fn http_4xx_aborts_by_default_no_downgrade() {
for status in [400, 401, 403, 404, 405, 429] {
assert!(
matches!(
classify_probe(DiscoverProbe::HttpStatus(status), false),
ProbeDecision::Abort(_)
),
"HTTP {status} must abort by default (no silent downgrade)"
);
}
}
#[test]
fn legacy_gateway_hatch_allows_only_404_405_fallback() {
assert_eq!(
classify_probe(DiscoverProbe::HttpStatus(404), true),
ProbeDecision::FallbackTo2025
);
assert_eq!(
classify_probe(DiscoverProbe::HttpStatus(405), true),
ProbeDecision::FallbackTo2025
);
for status in [400, 401, 403, 500] {
assert!(
matches!(
classify_probe(DiscoverProbe::HttpStatus(status), true),
ProbeDecision::Abort(_)
),
"HTTP {status} must abort even with the legacy-gateway hatch on"
);
}
}
}