Skip to main content

protocol_droid/domain/
negotiation.rs

1//! Negotiation — turning what a peer asked for into the version this session
2//! will speak, and refusing loudly when the two sides cannot agree.
3//!
4//! Every function here is pure. There is no session registry, no I/O, and no
5//! clock: negotiation is a decision, and a decision you can replay is a
6//! decision you can test. The stateful half — remembering which version a
7//! connection settled on — belongs to T10's session engine, which calls into
8//! this module rather than reimplementing it.
9//!
10//! # The three things negotiation has to get right
11//!
12//! **Echo when you can.** A client that asks for a revision the server speaks
13//! gets that revision back, not the server's favourite. Getting this wrong is
14//! rmcp issue #852, and it is the single most common negotiation bug: a server
15//! that always answers with its oldest or its newest revision forces the peer
16//! onto a version it did not ask for. [`negotiate_server`] is written so that
17//! the "echo" branch is the first one taken, and
18//! [`tests::a_supported_request_is_echoed_not_replaced`] pins it.
19//!
20//! **Never error on an unknown version.** A client asking for `2099-01-01` —
21//! a revision from the future, or a typo, or a client newer than this build —
22//! gets the server's latest back, not an error. Refusing is how a server
23//! becomes unusable the day a new revision ships; answering with what you do
24//! speak is how it stays usable. The error is reserved for the case where
25//! *nothing* can be agreed, which for a server with at least one revision is
26//! unreachable.
27//!
28//! **The header is an assertion, not a fallback.** A present
29//! `MCP-Protocol-Version` that disagrees with the negotiated version is a
30//! protocol error, and it is rejected rather than reconciled. Reconciling
31//! means guessing which of the two a request was really meant for.
32//!
33//! # Why the fallback is 2025-03-26 and not the oldest
34//!
35//! [`ASSUMED_VERSION_WITHOUT_SIGNAL`] is what a Streamable HTTP request with
36//! no version header and no other signal is assumed to be. It is
37//! `2025-03-26`, not `2024-11-05`, because the header requirement was
38//! introduced *at* `2025-06-18` (PR #548) and the version in force when the
39//! requirement did not yet exist is the one before it. Assuming the oldest
40//! revision instead would be the rmcp #852 bug wearing a different hat: it
41//! silently demotes a modern peer to a 2024 profile.
42
43use crate::domain::{
44    CapabilitySet, FeatureProfile, ProtocolVersion, SupportedVersions, TransportKind, resolve,
45};
46
47/// The version a request is assumed to be when it carries no version signal at
48/// all.
49///
50/// `2025-06-18` made the `MCP-Protocol-Version` header mandatory on HTTP
51/// (PR #548), and the revision in force immediately before that requirement
52/// existed is this one. See the module docs for why the oldest revision is the
53/// wrong fallback.
54#[must_use]
55pub const fn assumed_version_without_signal() -> ProtocolVersion {
56    ProtocolVersion::V2025_03_26
57}
58
59/// Whether `version` has an `initialize` handshake at all.
60///
61/// `2024-11-05` through `2025-11-25` define `InitializeRequest` and
62/// `InitializeResult`, both with `protocolVersion`, `capabilities` and
63/// `serverInfo` required. SEP-2575 removes the handshake in `2026-07-28`: the
64/// vendored schema for that revision has no `InitializeRequest` and no
65/// `InitializeResult`, and a session is established by the transport rather
66/// than by a round trip.
67///
68/// A question with no schema answer behind it, and so the reason it is asked
69/// here rather than being read off a feature row: the handshake is a
70/// message, and a removed message is not a behaviour that switched off. `ping`
71/// is a feature in [`FEATURES`](crate::domain::FEATURES) because something can
72/// still depend on it being refused; the handshake has nothing left to gate.
73///
74/// # Examples
75///
76/// ```
77/// use protocol_droid::domain::{ProtocolVersion, handshake_defined_in};
78///
79/// assert!(handshake_defined_in(ProtocolVersion::V2025_11_25));
80/// // SEP-2575: no `initialize` request or result exists in this revision.
81/// assert!(!handshake_defined_in(ProtocolVersion::V2026_07_28));
82/// ```
83#[must_use]
84pub const fn handshake_defined_in(version: ProtocolVersion) -> bool {
85    matches!(
86        version,
87        ProtocolVersion::V2024_11_05
88            | ProtocolVersion::V2025_03_26
89            | ProtocolVersion::V2025_06_18
90            | ProtocolVersion::V2025_11_25
91    )
92}
93
94/// Whether `server/discover` exists in this revision.
95///
96/// The mirror of [`handshake_defined_in`], and the two are complementary by
97/// construction: SEP-2575 removes the handshake from `2026-07-28` and adds
98/// this method in its place, so a revision has at most one of them.
99///
100/// # Examples
101///
102/// ```
103/// use protocol_droid::domain::{ProtocolVersion, discover_defined_in};
104///
105/// assert!(discover_defined_in(ProtocolVersion::V2026_07_28));
106/// // SEP-2575: `server/discover` does not exist before `2026-07-28`.
107/// assert!(!discover_defined_in(ProtocolVersion::V2025_11_25));
108/// ```
109#[must_use]
110pub const fn discover_defined_in(version: ProtocolVersion) -> bool {
111    // A revision published after `2026-07-28` is a draft of a later spec, and
112    // SEP-2575 is not a thing a later spec would take back. Answering for it
113    // is what keeps this crate's whole premise — every revision at once —
114    // from quietly becoming "every revision this build has heard of".
115    matches!(
116        version,
117        ProtocolVersion::V2026_07_28 | ProtocolVersion::Unknown(_)
118    )
119}
120
121/// What a peer calls itself.
122#[derive(Debug, Clone, PartialEq, Eq, Default)]
123pub struct PeerInfo {
124    /// The peer's declared name.
125    pub name: String,
126    /// The peer's declared version, free-form.
127    pub version: String,
128}
129
130impl PeerInfo {
131    /// A peer description.
132    #[must_use]
133    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
134        Self {
135            name: name.into(),
136            version: version.into(),
137        }
138    }
139}
140
141/// A session that has agreed on a version and a feature profile.
142///
143/// Deliberately not a connection: for `2026-07-28` this is per-request and
144/// nothing is retained, which is why [`Self::is_stateless`] is part of the
145/// value rather than a property of whatever holds it.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct NegotiatedSession {
148    /// The version both sides speak.
149    pub version: ProtocolVersion,
150    /// What this session may do.
151    pub profile: FeatureProfile,
152    /// What the client advertised.
153    pub client_caps: CapabilitySet,
154    /// What the server advertised.
155    pub server_caps: CapabilitySet,
156    /// The transport the session runs over.
157    pub transport: TransportKind,
158    /// The client implementation, when it identified itself.
159    pub client_info: Option<PeerInfo>,
160    /// The server implementation, when it identified itself.
161    pub server_info: Option<PeerInfo>,
162    /// Whether this profile is recomputed per request with nothing retained.
163    pub is_stateless: bool,
164}
165
166impl NegotiatedSession {
167    /// Builds a session from an agreed version and a pair of capability sets.
168    #[must_use]
169    pub fn new(
170        version: ProtocolVersion,
171        transport: TransportKind,
172        client_caps: CapabilitySet,
173        server_caps: CapabilitySet,
174    ) -> Self {
175        Self {
176            version,
177            profile: resolve(crate::domain::Negotiation {
178                version,
179                client_caps,
180                server_caps,
181                transport,
182            }),
183            client_caps,
184            server_caps,
185            transport,
186            client_info: None,
187            server_info: None,
188            is_stateless: version >= ProtocolVersion::V2026_07_28,
189        }
190    }
191
192    /// Attaches the client's identity.
193    #[must_use]
194    pub fn with_client_info(mut self, info: PeerInfo) -> Self {
195        self.client_info = Some(info);
196        self
197    }
198
199    /// Attaches the server's identity.
200    #[must_use]
201    pub fn with_server_info(mut self, info: PeerInfo) -> Self {
202        self.server_info = Some(info);
203        self
204    }
205
206    /// The four inputs [`profile`](Self::profile) was resolved from.
207    ///
208    /// [`Client::from_session`](crate::application::client::Client::from_session)
209    /// reads the profile off a session and needs nothing else, so this is here
210    /// for the callers that genuinely hold a bare [`Negotiation`] and want to
211    /// make one. [`new`](Self::new) already resolved from exactly these four
212    /// fields, so re-resolving the value this returns reproduces `profile`
213    /// rather than recomputing anything new — which is why it is a convenience
214    /// and not the way to build a client.
215    ///
216    /// [`Negotiation`]: crate::domain::Negotiation
217    #[must_use]
218    pub const fn negotiation(&self) -> crate::domain::Negotiation {
219        crate::domain::Negotiation {
220            version: self.version,
221            client_caps: self.client_caps,
222            server_caps: self.server_caps,
223            transport: self.transport,
224        }
225    }
226
227    /// Whether the session may do `feature`.
228    #[must_use]
229    pub fn allows(&self, feature: crate::domain::Feature) -> bool {
230        self.profile.allows(feature)
231    }
232
233    /// How many features the session may do.
234    #[must_use]
235    pub const fn len(&self) -> u32 {
236        self.profile.len()
237    }
238
239    /// Whether the session may do nothing at all.
240    #[must_use]
241    pub const fn is_empty(&self) -> bool {
242        self.profile.is_empty()
243    }
244}
245
246/// Why negotiation could not produce a session.
247///
248/// Every variant is a case where answering with a fallback would be a guess
249/// about what the peer meant. Cases where a fallback is *specified* — an
250/// unknown version, a missing header — are not errors and do not appear here.
251#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
252pub enum NegotiationError {
253    /// The server answered with a revision this client cannot speak, and the
254    /// client has no revision in common with it.
255    #[error(
256        "server answered with protocol version {answered}, which this client does not support; \
257         it supports {supported}"
258    )]
259    UnsupportedServerVersion {
260        /// What the server answered.
261        answered: ProtocolVersion,
262        /// What this client can speak, ascending.
263        supported: String,
264    },
265
266    /// A present `MCP-Protocol-Version` header disagrees with the negotiated
267    /// version.
268    ///
269    /// A rejection rather than a reconciliation: a header that says one thing
270    /// while the session says another is a client that has lost track, and
271    /// picking either would be guessing.
272    #[error("MCP-Protocol-Version header says {header}, but the session negotiated {negotiated}")]
273    HeaderMismatch {
274        /// The negotiated version.
275        negotiated: ProtocolVersion,
276        /// The version the header named, verbatim.
277        header: String,
278    },
279
280    /// The `MCP-Protocol-Version` header was not a version this build knows.
281    #[error("MCP-Protocol-Version header {header:?} is not a protocol version")]
282    HeaderNotAVersion {
283        /// The header value, verbatim.
284        header: String,
285    },
286
287    /// A stateless request carried no version in `_meta`, which `2026-07-28`
288    /// requires.
289    ///
290    /// This one has no fallback on purpose. The whole point of the stateless
291    /// lifecycle is that every request is self-describing, so a request that
292    /// is not has left the only signal the protocol provides.
293    #[error(
294        "stateless {version} request carried no io.modelcontextprotocol/protocolVersion in _meta; \
295         the stateless lifecycle has no fallback because it has no session to fall back to"
296    )]
297    MissingStatelessVersion {
298        /// The lifecycle that demanded it.
299        version: ProtocolVersion,
300    },
301}
302
303/// What the server decided to speak.
304///
305/// Echo and counter-offer are different outcomes and callers need to tell them
306/// apart: a bridge that was downgraded wants to log it, and
307/// `server/discover` advertises what is *supported* rather than what a
308/// particular client was given.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum ServerDecision {
311    /// The client asked for something the server speaks, and got it.
312    Echoed(ProtocolVersion),
313    /// The client asked for something the server does not speak. Not an error:
314    /// the server answers with its latest, per the lifecycle spec.
315    CounterOffered {
316        /// What the client asked for.
317        offered: ProtocolVersion,
318        /// What the server speaks instead.
319        answer: ProtocolVersion,
320    },
321}
322
323impl ServerDecision {
324    /// The version the session will speak either way.
325    #[must_use]
326    pub const fn version(self) -> ProtocolVersion {
327        match self {
328            Self::Echoed(version)
329            | Self::CounterOffered {
330                answer: version, ..
331            } => version,
332        }
333    }
334
335    /// Whether the client was downgraded.
336    #[must_use]
337    pub const fn is_counter_offer(self) -> bool {
338        matches!(self, Self::CounterOffered { .. })
339    }
340}
341
342/// Server-side version negotiation.
343///
344/// Echoes the offered version when the server supports it, and otherwise answers
345/// with the server's latest. Never fails: the specification says a client
346/// asking for `2099-01-01` gets the server's latest back, and a server that
347/// errors instead stops working the day a new revision is published.
348///
349/// # Examples
350///
351/// ```
352/// use protocol_droid::domain::{ProtocolVersion, ServerDecision, SupportedVersions, negotiate_server};
353///
354/// let all = SupportedVersions::all().unwrap();
355/// // The echo branch, which is the one that gets missed.
356/// assert_eq!(
357///     negotiate_server(ProtocolVersion::V2025_03_26, &all),
358///     ServerDecision::Echoed(ProtocolVersion::V2025_03_26),
359/// );
360/// // An unknown future version is answered, not rejected.
361/// let decision = negotiate_server(ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap(), &all);
362/// assert!(decision.is_counter_offer());
363/// ```
364#[must_use]
365pub fn negotiate_server(offered: ProtocolVersion, supported: &SupportedVersions) -> ServerDecision {
366    if supported.contains(&offered) {
367        ServerDecision::Echoed(offered)
368    } else {
369        ServerDecision::CounterOffered {
370            offered,
371            answer: supported.latest_handshake(),
372        }
373    }
374}
375
376/// Client-side version negotiation.
377///
378/// The client offered `preferred`; the server answered `answered`. The client
379/// accepts when it can speak the answer, and rejects with
380/// [`NegotiationError::UnsupportedServerVersion`] when it cannot.
381///
382/// A rejection here means disconnect, per the lifecycle spec — not a retry with
383/// a different version, and not acceptance of a version the client cannot parse.
384///
385/// # Examples
386///
387/// ```
388/// use protocol_droid::domain::{ProtocolVersion, negotiate_client};
389///
390/// let all = protocol_droid::domain::SupportedVersions::all().unwrap();
391/// let accepted =
392///     negotiate_client(ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, &all);
393/// assert_eq!(accepted.unwrap(), ProtocolVersion::V2025_06_18);
394/// ```
395///
396/// # Errors
397///
398/// [`NegotiationError::UnsupportedServerVersion`] when the client's supported
399/// set and the server's answer are disjoint.
400pub fn negotiate_client(
401    _preferred: ProtocolVersion,
402    answered: ProtocolVersion,
403    supported: &SupportedVersions,
404) -> Result<ProtocolVersion, NegotiationError> {
405    if supported.contains(&answered) {
406        return Ok(answered);
407    }
408    Err(NegotiationError::UnsupportedServerVersion {
409        answered,
410        supported: format_versions(supported),
411    })
412}
413
414/// What a stateless `2026-07-28` request said about itself.
415#[derive(Debug, Clone, PartialEq, Eq, Default)]
416pub struct StatelessRequest {
417    /// `io.modelcontextprotocol/protocolVersion` from the request's `_meta`.
418    pub version: Option<ProtocolVersion>,
419    /// The capabilities the client advertised in `_meta`.
420    pub client_caps: CapabilitySet,
421    /// The client identity from `io.modelcontextprotocol/clientInfo`.
422    pub client_info: Option<PeerInfo>,
423    /// The server identity to stamp into each result's `_meta`.
424    pub server_info: Option<PeerInfo>,
425}
426
427impl StatelessRequest {
428    /// A request with nothing in its `_meta`.
429    #[must_use]
430    pub const fn empty() -> Self {
431        Self {
432            version: None,
433            client_caps: CapabilitySet::empty(),
434            client_info: None,
435            server_info: None,
436        }
437    }
438
439    /// The version the request advertised in its `_meta`.
440    ///
441    /// A request whose `_meta` carried no version leaves this unset, and
442    /// the server picks the revision to answer on.
443    #[must_use]
444    pub const fn with_version(mut self, version: ProtocolVersion) -> Self {
445        self.version = Some(version);
446        self
447    }
448
449    /// Replaces the advertised capability set rather than merging into it. A
450    /// request that advertised none carries an empty set.
451    #[must_use]
452    pub const fn with_client_caps(mut self, caps: CapabilitySet) -> Self {
453        self.client_caps = caps;
454        self
455    }
456}
457
458/// Stateless per-request negotiation, as `2026-07-28` defines it.
459///
460/// Every request carries its own version and client capabilities in `_meta`,
461/// so the profile is computed from the request alone and nothing is retained
462/// between requests. This is why [`NegotiatedSession::is_stateless`] is true
463/// for a session built here, and why there is no fallback when the version is
464/// missing: with no session, there is nothing to fall back to.
465///
466/// The server's own capabilities come from `server_caps`, which is the server's
467/// configuration rather than anything the request said — the request cannot
468/// grant the server a capability.
469///
470/// # Errors
471///
472/// [`NegotiationError::MissingStatelessVersion`] when the request carries no
473/// `io.modelcontextprotocol/protocolVersion`.
474///
475/// [`NegotiationError::UnsupportedServerVersion`] when the request names a
476/// revision the server does not speak and the server has no revision to offer.
477///
478/// # Examples
479///
480/// ```
481/// use protocol_droid::domain::{
482///     Capability, CapabilitySet, ProtocolVersion, StatelessRequest, SupportedVersions,
483///     TransportKind, negotiate_stateless,
484/// };
485///
486/// let server = CapabilitySet::empty().with(Capability::Tools);
487/// let session = negotiate_stateless(
488///     &SupportedVersions::all().unwrap(),
489///     server,
490///     TransportKind::Stdio,
491///     StatelessRequest::empty()
492///         .with_version(ProtocolVersion::V2026_07_28)
493///         .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
494/// )?;
495/// assert!(session.is_stateless);
496/// # Ok::<(), protocol_droid::domain::NegotiationError>(())
497/// ```
498pub fn negotiate_stateless(
499    supported: &SupportedVersions,
500    server_caps: CapabilitySet,
501    transport: TransportKind,
502    request: StatelessRequest,
503) -> Result<NegotiatedSession, NegotiationError> {
504    let offered = request
505        .version
506        .ok_or(NegotiationError::MissingStatelessVersion {
507            version: ProtocolVersion::V2026_07_28,
508        })?;
509
510    // Same rule as the stateful path, because a stateless request is still a
511    // client asking for a version: an unknown one is answered, not rejected.
512    let version = negotiate_server(offered, supported).version();
513    // A capability set from a newer revision cannot switch on a feature the
514    // agreed revision cannot express.
515    let client_caps = request.client_caps.restricted_to(version);
516
517    let mut session = NegotiatedSession::new(version, transport, client_caps, server_caps);
518    session.is_stateless = true;
519    if let Some(info) = request.client_info {
520        session.client_info = Some(info);
521    }
522    if let Some(info) = request.server_info {
523        session.server_info = Some(info);
524    }
525    Ok(session)
526}
527
528/// Checks an `MCP-Protocol-Version` header against the negotiated version.
529///
530/// Three outcomes, and the middle one is the point:
531///
532/// - Header agrees — `Ok`.
533/// - Header disagrees — [`NegotiationError::HeaderMismatch`]. Rejected, not
534///   reconciled.
535/// - Header absent — `Ok`, and the caller decides what to assume via
536///   [`assumed_version_without_signal`]. Absence is not disagreement.
537///
538/// The header only became mandatory at `2025-06-18` (PR #548). Before that a
539/// Streamable HTTP request carried no version, so requiring one would reject
540/// peers the specification had not yet required anything of.
541///
542/// # Errors
543///
544/// [`NegotiationError::HeaderMismatch`] when the header names a different
545/// revision than the session negotiated.
546///
547/// [`NegotiationError::HeaderNotAVersion`] when the header is not a version
548/// this build can parse. That is distinct from a mismatch: a malformed header
549/// is a client that is broken, not one that disagrees.
550///
551/// # Examples
552///
553/// ```
554/// use protocol_droid::domain::{ProtocolVersion, check_protocol_header};
555///
556/// let negotiated = ProtocolVersion::V2025_06_18;
557/// assert!(check_protocol_header(negotiated, Some("2025-06-18")).is_ok());
558/// assert!(check_protocol_header(negotiated, Some("2024-11-05")).is_err());
559/// assert!(check_protocol_header(negotiated, None).is_ok());
560/// ```
561pub fn check_protocol_header(
562    negotiated: ProtocolVersion,
563    header: Option<&str>,
564) -> Result<(), NegotiationError> {
565    let Some(header) = header else {
566        return Ok(());
567    };
568    let stated = header.trim().parse::<ProtocolVersion>().map_err(|_| {
569        NegotiationError::HeaderNotAVersion {
570            header: header.to_owned(),
571        }
572    })?;
573    if stated == negotiated {
574        return Ok(());
575    }
576    Err(NegotiationError::HeaderMismatch {
577        negotiated,
578        header: header.to_owned(),
579    })
580}
581
582/// The version a request is assumed to be, given whatever signals it carries.
583///
584/// `signal` is the session's negotiated version, or the `_meta` version on a
585/// stateless request. `header` is the HTTP header. The session wins: a header
586/// that disagreed with the session would already have been rejected by
587/// [`check_protocol_header`], and a session is a stronger statement of intent
588/// than a header.
589///
590/// With neither, the request is assumed to be
591/// [`assumed_version_without_signal`].
592///
593/// # Examples
594///
595/// ```
596/// use protocol_droid::domain::{ProtocolVersion, assumed_version};
597///
598/// // Nothing to go on at all.
599/// assert_eq!(assumed_version(None, None), ProtocolVersion::V2025_03_26);
600/// // A header beats the fallback.
601/// assert_eq!(
602///     assumed_version(Some("2025-11-25"), None),
603///     ProtocolVersion::V2025_11_25,
604/// );
605/// ```
606#[must_use]
607pub fn assumed_version(header: Option<&str>, signal: Option<ProtocolVersion>) -> ProtocolVersion {
608    if let Some(signal) = signal {
609        return signal;
610    }
611    if let Some(version) = header.and_then(|h| h.trim().parse::<ProtocolVersion>().ok()) {
612        return version;
613    }
614    assumed_version_without_signal()
615}
616
617/// `supported` rendered for an error message.
618fn format_versions(supported: &SupportedVersions) -> String {
619    let mut out = String::new();
620    for (index, version) in supported.as_slice().iter().enumerate() {
621        if index > 0 {
622            out.push_str(", ");
623        }
624        out.push_str(&version.to_string());
625    }
626    out
627}
628
629/// The capability names the stateless `_meta` is allowed to carry.
630///
631/// `2026-07-28` defines exactly two reserved keys, and the names are the whole
632/// contract — a peer that misspells `protocolVersion` gets no version, which
633/// is a diagnosable failure rather than a silent default.
634pub mod stateless_meta_keys {
635    /// `io.modelcontextprotocol/protocolVersion`.
636    pub const PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
637    /// `io.modelcontextprotocol/clientCapabilities`.
638    pub const CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
639    /// `io.modelcontextprotocol/clientInfo`.
640    pub const CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo";
641    /// `io.modelcontextprotocol/serverInfo`.
642    pub const SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
643}
644
645/// Whether a `_meta` key is one the stateless lifecycle reserves.
646#[must_use]
647pub fn is_reserved_meta_key(key: &str) -> bool {
648    matches!(
649        key,
650        stateless_meta_keys::PROTOCOL_VERSION
651            | stateless_meta_keys::CLIENT_CAPABILITIES
652            | stateless_meta_keys::CLIENT_INFO
653            | stateless_meta_keys::SERVER_INFO
654    )
655}
656
657#[cfg(test)]
658mod tests {
659    // Test code unwraps deliberately: the strict profile exists to keep
660    // production paths fallible, and a test that cannot unwrap cannot assert.
661    #![expect(
662        clippy::panic,
663        clippy::unwrap_used,
664        reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
665    )]
666
667    use super::{
668        NegotiatedSession, NegotiationError, ServerDecision, StatelessRequest, assumed_version,
669        assumed_version_without_signal, check_protocol_header, is_reserved_meta_key,
670        negotiate_client, negotiate_server, negotiate_stateless, stateless_meta_keys,
671    };
672    use crate::domain::{
673        Capability, CapabilitySet, Feature, ProtocolVersion, SupportedVersions, TransportKind,
674    };
675
676    fn all() -> SupportedVersions {
677        SupportedVersions::all().unwrap()
678    }
679
680    fn subset(versions: &[ProtocolVersion]) -> SupportedVersions {
681        SupportedVersions::new(versions.to_vec()).unwrap()
682    }
683
684    /// The rmcp #852 regression, by name.
685    ///
686    /// rmcp answered every client with `2024-11-05` regardless of what was
687    /// asked for. A server that does that forces a modern client onto a
688    /// year-old profile, and the client's features silently vanish. This is the
689    /// test that fails if someone "simplifies" `negotiate_server` to always
690    /// return the earliest or the latest.
691    #[test]
692    fn a_supported_request_is_echoed_not_replaced() {
693        let supported = all();
694        // Every revision the server speaks comes back as itself — including the
695        // newest, which an "always answer latest" implementation gets right by
696        // accident, and the oldest, which an "always answer oldest" one gets
697        // right by accident. Neither would survive the middle rows.
698        for version in ProtocolVersion::KNOWN {
699            assert_eq!(
700                negotiate_server(version, &supported),
701                ServerDecision::Echoed(version),
702                "{version} was not echoed",
703            );
704        }
705    }
706
707    /// The same property, from the other end: the answer is never a revision
708    /// the client did not ask for *unless* the client asked for something we
709    /// cannot speak.
710    #[test]
711    fn the_answer_is_always_one_the_server_speaks() {
712        let supported = all();
713        for offered in ProtocolVersion::KNOWN {
714            let answer = negotiate_server(offered, &supported).version();
715            assert!(supported.contains(&answer), "{offered} -> {answer}");
716        }
717        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
718        assert_eq!(
719            negotiate_server(future, &supported),
720            ServerDecision::CounterOffered {
721                offered: future,
722                // Not `2026-07-28`: SEP-2575 removed `initialize` from that
723                // revision, so counter-offering it would send the peer to a
724                // protocol on which the handshake it just attempted does not
725                // exist.
726                answer: ProtocolVersion::V2025_11_25,
727            },
728        );
729    }
730
731    /// Scenario: echo-supported.
732    ///
733    /// A `2025-03-26` client gets `2025-03-26` back, and a profile that
734    /// excludes everything added later.
735    #[test]
736    fn echo_supported_scenario() {
737        let supported = all();
738        let decision = negotiate_server(ProtocolVersion::V2025_03_26, &supported);
739        assert_eq!(
740            decision,
741            ServerDecision::Echoed(ProtocolVersion::V2025_03_26)
742        );
743
744        let caps = CapabilitySet::all_for(ProtocolVersion::V2025_03_26);
745        let session = NegotiatedSession::new(decision.version(), TransportKind::Stdio, caps, caps);
746        assert_eq!(session.version, ProtocolVersion::V2025_03_26);
747        assert!(!session.allows(Feature::Elicitation), "added 2025-06-18");
748        assert!(
749            !session.allows(Feature::ToolStructuredOutput),
750            "added 2025-06-18"
751        );
752        assert!(!session.allows(Feature::Tasks), "added 2025-11-25");
753        // And it does have what `2025-03-26` defined.
754        assert!(session.allows(Feature::ToolAnnotations));
755        assert!(session.allows(Feature::Completions));
756    }
757
758    /// Scenario: unknown-version. No error, an answer.
759    #[test]
760    fn unknown_version_scenario() {
761        let supported = all();
762        for raw in ["2099-01-01", "2030-06-15", "not-a-date-at-all"] {
763            let offered = if raw == "not-a-date-at-all" {
764                ProtocolVersion::unknown_after_known("2027-12-31".parse().unwrap()).unwrap()
765            } else {
766                ProtocolVersion::unknown_after_known(raw.parse().unwrap()).unwrap()
767            };
768            let decision = negotiate_server(offered, &supported);
769            assert!(decision.is_counter_offer(), "{raw} should counter-offer");
770            assert_eq!(decision.version(), ProtocolVersion::V2025_11_25);
771        }
772    }
773
774    /// Scenario: stateless-discover. A `2026-07-28` request with no prior
775    /// initialize gets a per-request profile, and nothing is retained.
776    #[test]
777    fn stateless_discover_scenario() {
778        let version = ProtocolVersion::V2026_07_28;
779        let client_caps = CapabilitySet::all_for(version);
780        let server_caps = CapabilitySet::all_for(version);
781
782        let session = negotiate_stateless(
783            &all(),
784            server_caps,
785            TransportKind::Stdio,
786            StatelessRequest::empty()
787                .with_version(version)
788                .with_client_caps(client_caps),
789        )
790        .unwrap();
791
792        assert!(session.is_stateless, "2026-07-28 is stateless");
793        assert_eq!(session.version, version);
794        assert!(session.allows(Feature::ServerDiscover));
795        assert!(session.allows(Feature::Tools));
796        // The stateless lifecycle's own feature is on.
797        assert!(session.allows(Feature::StatelessLifecycle));
798    }
799
800    /// Two stateless requests are independent. Nothing is retained, so the
801    /// second one's profile is a function of the second request alone.
802    #[test]
803    fn stateless_requests_do_not_bleed_into_each_other() {
804        let server_caps = CapabilitySet::all();
805        let newer = negotiate_stateless(
806            &all(),
807            server_caps,
808            TransportKind::Stdio,
809            StatelessRequest::empty()
810                .with_version(ProtocolVersion::V2026_07_28)
811                .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
812        )
813        .unwrap();
814        let older = negotiate_stateless(
815            &all(),
816            server_caps,
817            TransportKind::Stdio,
818            StatelessRequest::empty()
819                .with_version(ProtocolVersion::V2024_11_05)
820                .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2024_11_05)),
821        )
822        .unwrap();
823        assert!(newer.allows(Feature::ServerDiscover));
824        assert!(!older.allows(Feature::ServerDiscover));
825        assert!(newer.len() > older.len());
826    }
827
828    #[test]
829    fn a_stateless_request_with_no_version_is_rejected() {
830        let error = negotiate_stateless(
831            &all(),
832            CapabilitySet::all(),
833            TransportKind::Stdio,
834            StatelessRequest::empty(),
835        )
836        .unwrap_err();
837        assert!(matches!(
838            error,
839            NegotiationError::MissingStatelessVersion { .. }
840        ));
841    }
842
843    /// A stateless request carrying a newer revision's capabilities cannot
844    /// switch on a feature the agreed revision cannot express.
845    #[test]
846    fn stateless_caps_are_restricted_to_the_agreed_revision() {
847        let session = negotiate_stateless(
848            &all(),
849            CapabilitySet::all(),
850            TransportKind::Stdio,
851            StatelessRequest::empty()
852                .with_version(ProtocolVersion::V2024_11_05)
853                .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
854        )
855        .unwrap();
856        assert!(!session.client_caps.contains(Capability::Elicitation));
857        assert!(!session.client_caps.contains(Capability::Extensions));
858        assert!(session.client_caps.contains(Capability::Sampling));
859        assert!(!session.allows(Feature::Elicitation));
860    }
861
862    /// The full (offered x supported) table, as the task asks.
863    #[test]
864    fn the_whole_offered_by_supported_table() {
865        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
866        let offered_by =
867            |versions: &[ProtocolVersion]| -> Vec<ProtocolVersion> { versions.to_vec() };
868        let _ = offered_by;
869
870        for supported_slice in [
871            vec![ProtocolVersion::V2024_11_05],
872            vec![ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_06_18],
873            vec![ProtocolVersion::V2025_11_25, ProtocolVersion::V2026_07_28],
874            ProtocolVersion::KNOWN.to_vec(),
875        ] {
876            let supported = subset(&supported_slice);
877            let mut offered = ProtocolVersion::KNOWN.to_vec();
878            offered.push(future);
879
880            for offered_version in offered {
881                let decision = negotiate_server(offered_version, &supported);
882                let expected_echo = supported.contains(&offered_version);
883                assert_eq!(
884                    decision.is_counter_offer(),
885                    !expected_echo,
886                    "{offered_version} against {supported_slice:?}",
887                );
888                // Whatever happened, the answer is the offered version or the
889                // server's latest, and always something the server speaks.
890                let answer = decision.version();
891                assert!(supported.contains(&answer), "{offered_version} -> {answer}");
892                if !expected_echo {
893                    assert_eq!(answer, supported.latest_handshake());
894                }
895            }
896        }
897    }
898
899    #[test]
900    fn the_client_accepts_a_version_it_speaks() {
901        let supported = all();
902        for answered in ProtocolVersion::KNOWN {
903            let accepted = negotiate_client(ProtocolVersion::V2026_07_28, answered, &supported);
904            assert_eq!(accepted.unwrap(), answered);
905        }
906    }
907
908    #[test]
909    fn the_client_rejects_a_version_it_does_not_speak() {
910        // A server that only speaks 2024 cannot be talked to by a client that
911        // only speaks 2026.
912        let client = subset(&[ProtocolVersion::V2026_07_28]);
913        let error = negotiate_client(
914            ProtocolVersion::V2026_07_28,
915            ProtocolVersion::V2024_11_05,
916            &client,
917        )
918        .unwrap_err();
919        match error {
920            NegotiationError::UnsupportedServerVersion { answered, .. } => {
921                assert_eq!(answered, ProtocolVersion::V2024_11_05);
922            }
923            other => panic!("expected UnsupportedServerVersion, got {other:?}"),
924        }
925        // And the error names what the client can speak, which is the only
926        // thing the operator can act on.
927        let rendered = negotiate_client(
928            ProtocolVersion::V2026_07_28,
929            ProtocolVersion::V2024_11_05,
930            &client,
931        )
932        .unwrap_err()
933        .to_string();
934        assert!(rendered.contains("2026-07-28"), "{rendered}");
935    }
936
937    /// A future version answered by a server is a rejection, because the client
938    /// cannot parse it — the mirror image of the server's counter-offer.
939    #[test]
940    fn a_client_cannot_accept_a_future_answer() {
941        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
942        assert!(negotiate_client(ProtocolVersion::V2026_07_28, future, &all()).is_err());
943    }
944
945    #[test]
946    fn the_header_is_checked_not_reconciled() {
947        let negotiated = ProtocolVersion::V2025_06_18;
948        assert!(check_protocol_header(negotiated, Some("2025-06-18")).is_ok());
949        // Surrounding whitespace is a transport detail, not a disagreement.
950        assert!(check_protocol_header(negotiated, Some("  2025-06-18  ")).is_ok());
951
952        for wrong in ["2024-11-05", "2026-07-28", "2025-03-26"] {
953            assert!(
954                check_protocol_header(negotiated, Some(wrong)).is_err(),
955                "{wrong} should not be accepted"
956            );
957        }
958    }
959
960    #[test]
961    fn a_missing_header_is_not_a_disagreement() {
962        let negotiated = ProtocolVersion::V2025_06_18;
963        assert!(check_protocol_header(negotiated, None).is_ok());
964    }
965
966    #[test]
967    fn a_malformed_header_is_distinct_from_a_mismatch() {
968        let error =
969            check_protocol_header(ProtocolVersion::V2025_06_18, Some("banana")).unwrap_err();
970        assert!(
971            matches!(error, NegotiationError::HeaderNotAVersion { .. }),
972            "{error:?}"
973        );
974        let error =
975            check_protocol_header(ProtocolVersion::V2025_06_18, Some("DRAFT-2025-v3")).unwrap_err();
976        assert!(
977            matches!(error, NegotiationError::HeaderNotAVersion { .. }),
978            "{error:?}",
979        );
980    }
981
982    /// The fallback is `2025-03-26`, not the oldest revision. Getting this
983    /// wrong is rmcp #852 in a different disguise.
984    #[test]
985    fn the_fallback_is_not_the_oldest_revision() {
986        assert_eq!(
987            assumed_version_without_signal(),
988            ProtocolVersion::V2025_03_26
989        );
990        assert_ne!(
991            assumed_version_without_signal(),
992            ProtocolVersion::earliest_known()
993        );
994        assert_eq!(assumed_version(None, None), ProtocolVersion::V2025_03_26);
995    }
996
997    #[test]
998    fn a_signal_beats_the_header_and_both_beat_the_fallback() {
999        assert_eq!(
1000            assumed_version(Some("2024-11-05"), Some(ProtocolVersion::V2026_07_28)),
1001            ProtocolVersion::V2026_07_28,
1002        );
1003        assert_eq!(
1004            assumed_version(Some("2025-11-25"), None),
1005            ProtocolVersion::V2025_11_25,
1006        );
1007        // A header that does not parse falls through to the fallback rather
1008        // than propagating a parse failure: the caller has already been told
1009        // about the malformed header by `check_protocol_header`.
1010        assert_eq!(
1011            assumed_version(Some("garbage"), None),
1012            ProtocolVersion::V2025_03_26
1013        );
1014        // A future version in the header is still a version.
1015        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
1016        assert_eq!(assumed_version(Some("2099-01-01"), None), future);
1017    }
1018
1019    /// `NegotiatedSession::is_stateless` tracks the lifecycle, not the caller.
1020    #[test]
1021    fn statelessness_follows_the_revision() {
1022        for version in ProtocolVersion::KNOWN {
1023            let session = NegotiatedSession::new(
1024                version,
1025                TransportKind::Stdio,
1026                CapabilitySet::all(),
1027                CapabilitySet::all(),
1028            );
1029            assert_eq!(
1030                session.is_stateless,
1031                version >= ProtocolVersion::V2026_07_28,
1032                "{version}"
1033            );
1034        }
1035    }
1036
1037    #[test]
1038    fn the_reserved_meta_keys_are_the_documented_ones() {
1039        for key in [
1040            stateless_meta_keys::PROTOCOL_VERSION,
1041            stateless_meta_keys::CLIENT_CAPABILITIES,
1042            stateless_meta_keys::CLIENT_INFO,
1043            stateless_meta_keys::SERVER_INFO,
1044        ] {
1045            assert!(is_reserved_meta_key(key), "{key}");
1046        }
1047        assert!(!is_reserved_meta_key(
1048            "io.modelcontextprotocol/subscriptionId"
1049        ));
1050        assert!(!is_reserved_meta_key("progressToken"));
1051        // The spellings are the contract, so they are asserted literally.
1052        assert_eq!(
1053            stateless_meta_keys::PROTOCOL_VERSION,
1054            "io.modelcontextprotocol/protocolVersion",
1055        );
1056    }
1057
1058    #[test]
1059    fn identity_is_carried_but_not_required() {
1060        let session = NegotiatedSession::new(
1061            ProtocolVersion::V2025_06_18,
1062            TransportKind::Stdio,
1063            CapabilitySet::all(),
1064            CapabilitySet::all(),
1065        );
1066        assert!(session.client_info.is_none());
1067        let profile = session.profile;
1068        let named = session
1069            .with_client_info(super::PeerInfo::new("test-client", "1.0"))
1070            .with_server_info(super::PeerInfo::new("protocol-droid", "0.1"));
1071        assert_eq!(named.client_info.as_ref().unwrap().name, "test-client");
1072        assert_eq!(named.server_info.as_ref().unwrap().name, "protocol-droid");
1073        // Identity does not change what the session may do.
1074        assert_eq!(named.profile, profile);
1075    }
1076}