use crate::domain::{
CapabilitySet, FeatureProfile, ProtocolVersion, SupportedVersions, TransportKind, resolve,
};
#[must_use]
pub const fn assumed_version_without_signal() -> ProtocolVersion {
ProtocolVersion::V2025_03_26
}
#[must_use]
pub const fn handshake_defined_in(version: ProtocolVersion) -> bool {
matches!(
version,
ProtocolVersion::V2024_11_05
| ProtocolVersion::V2025_03_26
| ProtocolVersion::V2025_06_18
| ProtocolVersion::V2025_11_25
)
}
#[must_use]
pub const fn discover_defined_in(version: ProtocolVersion) -> bool {
matches!(
version,
ProtocolVersion::V2026_07_28 | ProtocolVersion::Unknown(_)
)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PeerInfo {
pub name: String,
pub version: String,
}
impl PeerInfo {
#[must_use]
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiatedSession {
pub version: ProtocolVersion,
pub profile: FeatureProfile,
pub client_caps: CapabilitySet,
pub server_caps: CapabilitySet,
pub transport: TransportKind,
pub client_info: Option<PeerInfo>,
pub server_info: Option<PeerInfo>,
pub is_stateless: bool,
}
impl NegotiatedSession {
#[must_use]
pub fn new(
version: ProtocolVersion,
transport: TransportKind,
client_caps: CapabilitySet,
server_caps: CapabilitySet,
) -> Self {
Self {
version,
profile: resolve(crate::domain::Negotiation {
version,
client_caps,
server_caps,
transport,
}),
client_caps,
server_caps,
transport,
client_info: None,
server_info: None,
is_stateless: version >= ProtocolVersion::V2026_07_28,
}
}
#[must_use]
pub fn with_client_info(mut self, info: PeerInfo) -> Self {
self.client_info = Some(info);
self
}
#[must_use]
pub fn with_server_info(mut self, info: PeerInfo) -> Self {
self.server_info = Some(info);
self
}
#[must_use]
pub const fn negotiation(&self) -> crate::domain::Negotiation {
crate::domain::Negotiation {
version: self.version,
client_caps: self.client_caps,
server_caps: self.server_caps,
transport: self.transport,
}
}
#[must_use]
pub fn allows(&self, feature: crate::domain::Feature) -> bool {
self.profile.allows(feature)
}
#[must_use]
pub const fn len(&self) -> u32 {
self.profile.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.profile.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NegotiationError {
#[error(
"server answered with protocol version {answered}, which this client does not support; \
it supports {supported}"
)]
UnsupportedServerVersion {
answered: ProtocolVersion,
supported: String,
},
#[error("MCP-Protocol-Version header says {header}, but the session negotiated {negotiated}")]
HeaderMismatch {
negotiated: ProtocolVersion,
header: String,
},
#[error("MCP-Protocol-Version header {header:?} is not a protocol version")]
HeaderNotAVersion {
header: String,
},
#[error(
"stateless {version} request carried no io.modelcontextprotocol/protocolVersion in _meta; \
the stateless lifecycle has no fallback because it has no session to fall back to"
)]
MissingStatelessVersion {
version: ProtocolVersion,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerDecision {
Echoed(ProtocolVersion),
CounterOffered {
offered: ProtocolVersion,
answer: ProtocolVersion,
},
}
impl ServerDecision {
#[must_use]
pub const fn version(self) -> ProtocolVersion {
match self {
Self::Echoed(version)
| Self::CounterOffered {
answer: version, ..
} => version,
}
}
#[must_use]
pub const fn is_counter_offer(self) -> bool {
matches!(self, Self::CounterOffered { .. })
}
}
#[must_use]
pub fn negotiate_server(offered: ProtocolVersion, supported: &SupportedVersions) -> ServerDecision {
if supported.contains(&offered) {
ServerDecision::Echoed(offered)
} else {
ServerDecision::CounterOffered {
offered,
answer: supported.latest_handshake(),
}
}
}
pub fn negotiate_client(
_preferred: ProtocolVersion,
answered: ProtocolVersion,
supported: &SupportedVersions,
) -> Result<ProtocolVersion, NegotiationError> {
if supported.contains(&answered) {
return Ok(answered);
}
Err(NegotiationError::UnsupportedServerVersion {
answered,
supported: format_versions(supported),
})
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StatelessRequest {
pub version: Option<ProtocolVersion>,
pub client_caps: CapabilitySet,
pub client_info: Option<PeerInfo>,
pub server_info: Option<PeerInfo>,
}
impl StatelessRequest {
#[must_use]
pub const fn empty() -> Self {
Self {
version: None,
client_caps: CapabilitySet::empty(),
client_info: None,
server_info: None,
}
}
#[must_use]
pub const fn with_version(mut self, version: ProtocolVersion) -> Self {
self.version = Some(version);
self
}
#[must_use]
pub const fn with_client_caps(mut self, caps: CapabilitySet) -> Self {
self.client_caps = caps;
self
}
}
pub fn negotiate_stateless(
supported: &SupportedVersions,
server_caps: CapabilitySet,
transport: TransportKind,
request: StatelessRequest,
) -> Result<NegotiatedSession, NegotiationError> {
let offered = request
.version
.ok_or(NegotiationError::MissingStatelessVersion {
version: ProtocolVersion::V2026_07_28,
})?;
let version = negotiate_server(offered, supported).version();
let client_caps = request.client_caps.restricted_to(version);
let mut session = NegotiatedSession::new(version, transport, client_caps, server_caps);
session.is_stateless = true;
if let Some(info) = request.client_info {
session.client_info = Some(info);
}
if let Some(info) = request.server_info {
session.server_info = Some(info);
}
Ok(session)
}
pub fn check_protocol_header(
negotiated: ProtocolVersion,
header: Option<&str>,
) -> Result<(), NegotiationError> {
let Some(header) = header else {
return Ok(());
};
let stated = header.trim().parse::<ProtocolVersion>().map_err(|_| {
NegotiationError::HeaderNotAVersion {
header: header.to_owned(),
}
})?;
if stated == negotiated {
return Ok(());
}
Err(NegotiationError::HeaderMismatch {
negotiated,
header: header.to_owned(),
})
}
#[must_use]
pub fn assumed_version(header: Option<&str>, signal: Option<ProtocolVersion>) -> ProtocolVersion {
if let Some(signal) = signal {
return signal;
}
if let Some(version) = header.and_then(|h| h.trim().parse::<ProtocolVersion>().ok()) {
return version;
}
assumed_version_without_signal()
}
fn format_versions(supported: &SupportedVersions) -> String {
let mut out = String::new();
for (index, version) in supported.as_slice().iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
out.push_str(&version.to_string());
}
out
}
pub mod stateless_meta_keys {
pub const PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
pub const CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
pub const CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo";
pub const SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
}
#[must_use]
pub fn is_reserved_meta_key(key: &str) -> bool {
matches!(
key,
stateless_meta_keys::PROTOCOL_VERSION
| stateless_meta_keys::CLIENT_CAPABILITIES
| stateless_meta_keys::CLIENT_INFO
| stateless_meta_keys::SERVER_INFO
)
}
#[cfg(test)]
mod tests {
#![expect(
clippy::panic,
clippy::unwrap_used,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use super::{
NegotiatedSession, NegotiationError, ServerDecision, StatelessRequest, assumed_version,
assumed_version_without_signal, check_protocol_header, is_reserved_meta_key,
negotiate_client, negotiate_server, negotiate_stateless, stateless_meta_keys,
};
use crate::domain::{
Capability, CapabilitySet, Feature, ProtocolVersion, SupportedVersions, TransportKind,
};
fn all() -> SupportedVersions {
SupportedVersions::all().unwrap()
}
fn subset(versions: &[ProtocolVersion]) -> SupportedVersions {
SupportedVersions::new(versions.to_vec()).unwrap()
}
#[test]
fn a_supported_request_is_echoed_not_replaced() {
let supported = all();
for version in ProtocolVersion::KNOWN {
assert_eq!(
negotiate_server(version, &supported),
ServerDecision::Echoed(version),
"{version} was not echoed",
);
}
}
#[test]
fn the_answer_is_always_one_the_server_speaks() {
let supported = all();
for offered in ProtocolVersion::KNOWN {
let answer = negotiate_server(offered, &supported).version();
assert!(supported.contains(&answer), "{offered} -> {answer}");
}
let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
assert_eq!(
negotiate_server(future, &supported),
ServerDecision::CounterOffered {
offered: future,
answer: ProtocolVersion::V2025_11_25,
},
);
}
#[test]
fn echo_supported_scenario() {
let supported = all();
let decision = negotiate_server(ProtocolVersion::V2025_03_26, &supported);
assert_eq!(
decision,
ServerDecision::Echoed(ProtocolVersion::V2025_03_26)
);
let caps = CapabilitySet::all_for(ProtocolVersion::V2025_03_26);
let session = NegotiatedSession::new(decision.version(), TransportKind::Stdio, caps, caps);
assert_eq!(session.version, ProtocolVersion::V2025_03_26);
assert!(!session.allows(Feature::Elicitation), "added 2025-06-18");
assert!(
!session.allows(Feature::ToolStructuredOutput),
"added 2025-06-18"
);
assert!(!session.allows(Feature::Tasks), "added 2025-11-25");
assert!(session.allows(Feature::ToolAnnotations));
assert!(session.allows(Feature::Completions));
}
#[test]
fn unknown_version_scenario() {
let supported = all();
for raw in ["2099-01-01", "2030-06-15", "not-a-date-at-all"] {
let offered = if raw == "not-a-date-at-all" {
ProtocolVersion::unknown_after_known("2027-12-31".parse().unwrap()).unwrap()
} else {
ProtocolVersion::unknown_after_known(raw.parse().unwrap()).unwrap()
};
let decision = negotiate_server(offered, &supported);
assert!(decision.is_counter_offer(), "{raw} should counter-offer");
assert_eq!(decision.version(), ProtocolVersion::V2025_11_25);
}
}
#[test]
fn stateless_discover_scenario() {
let version = ProtocolVersion::V2026_07_28;
let client_caps = CapabilitySet::all_for(version);
let server_caps = CapabilitySet::all_for(version);
let session = negotiate_stateless(
&all(),
server_caps,
TransportKind::Stdio,
StatelessRequest::empty()
.with_version(version)
.with_client_caps(client_caps),
)
.unwrap();
assert!(session.is_stateless, "2026-07-28 is stateless");
assert_eq!(session.version, version);
assert!(session.allows(Feature::ServerDiscover));
assert!(session.allows(Feature::Tools));
assert!(session.allows(Feature::StatelessLifecycle));
}
#[test]
fn stateless_requests_do_not_bleed_into_each_other() {
let server_caps = CapabilitySet::all();
let newer = negotiate_stateless(
&all(),
server_caps,
TransportKind::Stdio,
StatelessRequest::empty()
.with_version(ProtocolVersion::V2026_07_28)
.with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
)
.unwrap();
let older = negotiate_stateless(
&all(),
server_caps,
TransportKind::Stdio,
StatelessRequest::empty()
.with_version(ProtocolVersion::V2024_11_05)
.with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2024_11_05)),
)
.unwrap();
assert!(newer.allows(Feature::ServerDiscover));
assert!(!older.allows(Feature::ServerDiscover));
assert!(newer.len() > older.len());
}
#[test]
fn a_stateless_request_with_no_version_is_rejected() {
let error = negotiate_stateless(
&all(),
CapabilitySet::all(),
TransportKind::Stdio,
StatelessRequest::empty(),
)
.unwrap_err();
assert!(matches!(
error,
NegotiationError::MissingStatelessVersion { .. }
));
}
#[test]
fn stateless_caps_are_restricted_to_the_agreed_revision() {
let session = negotiate_stateless(
&all(),
CapabilitySet::all(),
TransportKind::Stdio,
StatelessRequest::empty()
.with_version(ProtocolVersion::V2024_11_05)
.with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
)
.unwrap();
assert!(!session.client_caps.contains(Capability::Elicitation));
assert!(!session.client_caps.contains(Capability::Extensions));
assert!(session.client_caps.contains(Capability::Sampling));
assert!(!session.allows(Feature::Elicitation));
}
#[test]
fn the_whole_offered_by_supported_table() {
let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
let offered_by =
|versions: &[ProtocolVersion]| -> Vec<ProtocolVersion> { versions.to_vec() };
let _ = offered_by;
for supported_slice in [
vec![ProtocolVersion::V2024_11_05],
vec![ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_06_18],
vec![ProtocolVersion::V2025_11_25, ProtocolVersion::V2026_07_28],
ProtocolVersion::KNOWN.to_vec(),
] {
let supported = subset(&supported_slice);
let mut offered = ProtocolVersion::KNOWN.to_vec();
offered.push(future);
for offered_version in offered {
let decision = negotiate_server(offered_version, &supported);
let expected_echo = supported.contains(&offered_version);
assert_eq!(
decision.is_counter_offer(),
!expected_echo,
"{offered_version} against {supported_slice:?}",
);
let answer = decision.version();
assert!(supported.contains(&answer), "{offered_version} -> {answer}");
if !expected_echo {
assert_eq!(answer, supported.latest_handshake());
}
}
}
}
#[test]
fn the_client_accepts_a_version_it_speaks() {
let supported = all();
for answered in ProtocolVersion::KNOWN {
let accepted = negotiate_client(ProtocolVersion::V2026_07_28, answered, &supported);
assert_eq!(accepted.unwrap(), answered);
}
}
#[test]
fn the_client_rejects_a_version_it_does_not_speak() {
let client = subset(&[ProtocolVersion::V2026_07_28]);
let error = negotiate_client(
ProtocolVersion::V2026_07_28,
ProtocolVersion::V2024_11_05,
&client,
)
.unwrap_err();
match error {
NegotiationError::UnsupportedServerVersion { answered, .. } => {
assert_eq!(answered, ProtocolVersion::V2024_11_05);
}
other => panic!("expected UnsupportedServerVersion, got {other:?}"),
}
let rendered = negotiate_client(
ProtocolVersion::V2026_07_28,
ProtocolVersion::V2024_11_05,
&client,
)
.unwrap_err()
.to_string();
assert!(rendered.contains("2026-07-28"), "{rendered}");
}
#[test]
fn a_client_cannot_accept_a_future_answer() {
let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
assert!(negotiate_client(ProtocolVersion::V2026_07_28, future, &all()).is_err());
}
#[test]
fn the_header_is_checked_not_reconciled() {
let negotiated = ProtocolVersion::V2025_06_18;
assert!(check_protocol_header(negotiated, Some("2025-06-18")).is_ok());
assert!(check_protocol_header(negotiated, Some(" 2025-06-18 ")).is_ok());
for wrong in ["2024-11-05", "2026-07-28", "2025-03-26"] {
assert!(
check_protocol_header(negotiated, Some(wrong)).is_err(),
"{wrong} should not be accepted"
);
}
}
#[test]
fn a_missing_header_is_not_a_disagreement() {
let negotiated = ProtocolVersion::V2025_06_18;
assert!(check_protocol_header(negotiated, None).is_ok());
}
#[test]
fn a_malformed_header_is_distinct_from_a_mismatch() {
let error =
check_protocol_header(ProtocolVersion::V2025_06_18, Some("banana")).unwrap_err();
assert!(
matches!(error, NegotiationError::HeaderNotAVersion { .. }),
"{error:?}"
);
let error =
check_protocol_header(ProtocolVersion::V2025_06_18, Some("DRAFT-2025-v3")).unwrap_err();
assert!(
matches!(error, NegotiationError::HeaderNotAVersion { .. }),
"{error:?}",
);
}
#[test]
fn the_fallback_is_not_the_oldest_revision() {
assert_eq!(
assumed_version_without_signal(),
ProtocolVersion::V2025_03_26
);
assert_ne!(
assumed_version_without_signal(),
ProtocolVersion::earliest_known()
);
assert_eq!(assumed_version(None, None), ProtocolVersion::V2025_03_26);
}
#[test]
fn a_signal_beats_the_header_and_both_beat_the_fallback() {
assert_eq!(
assumed_version(Some("2024-11-05"), Some(ProtocolVersion::V2026_07_28)),
ProtocolVersion::V2026_07_28,
);
assert_eq!(
assumed_version(Some("2025-11-25"), None),
ProtocolVersion::V2025_11_25,
);
assert_eq!(
assumed_version(Some("garbage"), None),
ProtocolVersion::V2025_03_26
);
let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
assert_eq!(assumed_version(Some("2099-01-01"), None), future);
}
#[test]
fn statelessness_follows_the_revision() {
for version in ProtocolVersion::KNOWN {
let session = NegotiatedSession::new(
version,
TransportKind::Stdio,
CapabilitySet::all(),
CapabilitySet::all(),
);
assert_eq!(
session.is_stateless,
version >= ProtocolVersion::V2026_07_28,
"{version}"
);
}
}
#[test]
fn the_reserved_meta_keys_are_the_documented_ones() {
for key in [
stateless_meta_keys::PROTOCOL_VERSION,
stateless_meta_keys::CLIENT_CAPABILITIES,
stateless_meta_keys::CLIENT_INFO,
stateless_meta_keys::SERVER_INFO,
] {
assert!(is_reserved_meta_key(key), "{key}");
}
assert!(!is_reserved_meta_key(
"io.modelcontextprotocol/subscriptionId"
));
assert!(!is_reserved_meta_key("progressToken"));
assert_eq!(
stateless_meta_keys::PROTOCOL_VERSION,
"io.modelcontextprotocol/protocolVersion",
);
}
#[test]
fn identity_is_carried_but_not_required() {
let session = NegotiatedSession::new(
ProtocolVersion::V2025_06_18,
TransportKind::Stdio,
CapabilitySet::all(),
CapabilitySet::all(),
);
assert!(session.client_info.is_none());
let profile = session.profile;
let named = session
.with_client_info(super::PeerInfo::new("test-client", "1.0"))
.with_server_info(super::PeerInfo::new("protocol-droid", "0.1"));
assert_eq!(named.client_info.as_ref().unwrap().name, "test-client");
assert_eq!(named.server_info.as_ref().unwrap().name, "protocol-droid");
assert_eq!(named.profile, profile);
}
}