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    /// Sets the version.
440    #[must_use]
441    pub const fn with_version(mut self, version: ProtocolVersion) -> Self {
442        self.version = Some(version);
443        self
444    }
445
446    /// Sets the client capabilities.
447    #[must_use]
448    pub const fn with_client_caps(mut self, caps: CapabilitySet) -> Self {
449        self.client_caps = caps;
450        self
451    }
452}
453
454/// Stateless per-request negotiation, as `2026-07-28` defines it.
455///
456/// Every request carries its own version and client capabilities in `_meta`,
457/// so the profile is computed from the request alone and nothing is retained
458/// between requests. This is why [`NegotiatedSession::is_stateless`] is true
459/// for a session built here, and why there is no fallback when the version is
460/// missing: with no session, there is nothing to fall back to.
461///
462/// The server's own capabilities come from `server_caps`, which is the server's
463/// configuration rather than anything the request said — the request cannot
464/// grant the server a capability.
465///
466/// # Errors
467///
468/// [`NegotiationError::MissingStatelessVersion`] when the request carries no
469/// `io.modelcontextprotocol/protocolVersion`.
470///
471/// [`NegotiationError::UnsupportedServerVersion`] when the request names a
472/// revision the server does not speak and the server has no revision to offer.
473///
474/// # Examples
475///
476/// ```
477/// use protocol_droid::domain::{
478///     Capability, CapabilitySet, ProtocolVersion, StatelessRequest, SupportedVersions,
479///     TransportKind, negotiate_stateless,
480/// };
481///
482/// let server = CapabilitySet::empty().with(Capability::Tools);
483/// let session = negotiate_stateless(
484///     &SupportedVersions::all().unwrap(),
485///     server,
486///     TransportKind::Stdio,
487///     StatelessRequest::empty()
488///         .with_version(ProtocolVersion::V2026_07_28)
489///         .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
490/// )?;
491/// assert!(session.is_stateless);
492/// # Ok::<(), protocol_droid::domain::NegotiationError>(())
493/// ```
494pub fn negotiate_stateless(
495    supported: &SupportedVersions,
496    server_caps: CapabilitySet,
497    transport: TransportKind,
498    request: StatelessRequest,
499) -> Result<NegotiatedSession, NegotiationError> {
500    let offered = request
501        .version
502        .ok_or(NegotiationError::MissingStatelessVersion {
503            version: ProtocolVersion::V2026_07_28,
504        })?;
505
506    // Same rule as the stateful path, because a stateless request is still a
507    // client asking for a version: an unknown one is answered, not rejected.
508    let version = negotiate_server(offered, supported).version();
509    // A capability set from a newer revision cannot switch on a feature the
510    // agreed revision cannot express.
511    let client_caps = request.client_caps.restricted_to(version);
512
513    let mut session = NegotiatedSession::new(version, transport, client_caps, server_caps);
514    session.is_stateless = true;
515    if let Some(info) = request.client_info {
516        session.client_info = Some(info);
517    }
518    if let Some(info) = request.server_info {
519        session.server_info = Some(info);
520    }
521    Ok(session)
522}
523
524/// Checks an `MCP-Protocol-Version` header against the negotiated version.
525///
526/// Three outcomes, and the middle one is the point:
527///
528/// - Header agrees — `Ok`.
529/// - Header disagrees — [`NegotiationError::HeaderMismatch`]. Rejected, not
530///   reconciled.
531/// - Header absent — `Ok`, and the caller decides what to assume via
532///   [`assumed_version_without_signal`]. Absence is not disagreement.
533///
534/// The header only became mandatory at `2025-06-18` (PR #548). Before that a
535/// Streamable HTTP request carried no version, so requiring one would reject
536/// peers the specification had not yet required anything of.
537///
538/// # Errors
539///
540/// [`NegotiationError::HeaderMismatch`] when the header names a different
541/// revision than the session negotiated.
542///
543/// [`NegotiationError::HeaderNotAVersion`] when the header is not a version
544/// this build can parse. That is distinct from a mismatch: a malformed header
545/// is a client that is broken, not one that disagrees.
546///
547/// # Examples
548///
549/// ```
550/// use protocol_droid::domain::{ProtocolVersion, check_protocol_header};
551///
552/// let negotiated = ProtocolVersion::V2025_06_18;
553/// assert!(check_protocol_header(negotiated, Some("2025-06-18")).is_ok());
554/// assert!(check_protocol_header(negotiated, Some("2024-11-05")).is_err());
555/// assert!(check_protocol_header(negotiated, None).is_ok());
556/// ```
557pub fn check_protocol_header(
558    negotiated: ProtocolVersion,
559    header: Option<&str>,
560) -> Result<(), NegotiationError> {
561    let Some(header) = header else {
562        return Ok(());
563    };
564    let stated = header.trim().parse::<ProtocolVersion>().map_err(|_| {
565        NegotiationError::HeaderNotAVersion {
566            header: header.to_owned(),
567        }
568    })?;
569    if stated == negotiated {
570        return Ok(());
571    }
572    Err(NegotiationError::HeaderMismatch {
573        negotiated,
574        header: header.to_owned(),
575    })
576}
577
578/// The version a request is assumed to be, given whatever signals it carries.
579///
580/// `signal` is the session's negotiated version, or the `_meta` version on a
581/// stateless request. `header` is the HTTP header. The session wins: a header
582/// that disagreed with the session would already have been rejected by
583/// [`check_protocol_header`], and a session is a stronger statement of intent
584/// than a header.
585///
586/// With neither, the request is assumed to be
587/// [`assumed_version_without_signal`].
588///
589/// # Examples
590///
591/// ```
592/// use protocol_droid::domain::{ProtocolVersion, assumed_version};
593///
594/// // Nothing to go on at all.
595/// assert_eq!(assumed_version(None, None), ProtocolVersion::V2025_03_26);
596/// // A header beats the fallback.
597/// assert_eq!(
598///     assumed_version(Some("2025-11-25"), None),
599///     ProtocolVersion::V2025_11_25,
600/// );
601/// ```
602#[must_use]
603pub fn assumed_version(header: Option<&str>, signal: Option<ProtocolVersion>) -> ProtocolVersion {
604    if let Some(signal) = signal {
605        return signal;
606    }
607    if let Some(version) = header.and_then(|h| h.trim().parse::<ProtocolVersion>().ok()) {
608        return version;
609    }
610    assumed_version_without_signal()
611}
612
613/// `supported` rendered for an error message.
614fn format_versions(supported: &SupportedVersions) -> String {
615    let mut out = String::new();
616    for (index, version) in supported.as_slice().iter().enumerate() {
617        if index > 0 {
618            out.push_str(", ");
619        }
620        out.push_str(&version.to_string());
621    }
622    out
623}
624
625/// The capability names the stateless `_meta` is allowed to carry.
626///
627/// `2026-07-28` defines exactly two reserved keys, and the names are the whole
628/// contract — a peer that misspells `protocolVersion` gets no version, which
629/// is a diagnosable failure rather than a silent default.
630pub mod stateless_meta_keys {
631    /// `io.modelcontextprotocol/protocolVersion`.
632    pub const PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
633    /// `io.modelcontextprotocol/clientCapabilities`.
634    pub const CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
635    /// `io.modelcontextprotocol/clientInfo`.
636    pub const CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo";
637    /// `io.modelcontextprotocol/serverInfo`.
638    pub const SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
639}
640
641/// Whether a `_meta` key is one the stateless lifecycle reserves.
642#[must_use]
643pub fn is_reserved_meta_key(key: &str) -> bool {
644    matches!(
645        key,
646        stateless_meta_keys::PROTOCOL_VERSION
647            | stateless_meta_keys::CLIENT_CAPABILITIES
648            | stateless_meta_keys::CLIENT_INFO
649            | stateless_meta_keys::SERVER_INFO
650    )
651}
652
653#[cfg(test)]
654mod tests {
655    // Test code unwraps deliberately: the strict profile exists to keep
656    // production paths fallible, and a test that cannot unwrap cannot assert.
657    #![expect(
658        clippy::panic,
659        clippy::unwrap_used,
660        reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
661    )]
662
663    use super::{
664        NegotiatedSession, NegotiationError, ServerDecision, StatelessRequest, assumed_version,
665        assumed_version_without_signal, check_protocol_header, is_reserved_meta_key,
666        negotiate_client, negotiate_server, negotiate_stateless, stateless_meta_keys,
667    };
668    use crate::domain::{
669        Capability, CapabilitySet, Feature, ProtocolVersion, SupportedVersions, TransportKind,
670    };
671
672    fn all() -> SupportedVersions {
673        SupportedVersions::all().unwrap()
674    }
675
676    fn subset(versions: &[ProtocolVersion]) -> SupportedVersions {
677        SupportedVersions::new(versions.to_vec()).unwrap()
678    }
679
680    /// The rmcp #852 regression, by name.
681    ///
682    /// rmcp answered every client with `2024-11-05` regardless of what was
683    /// asked for. A server that does that forces a modern client onto a
684    /// year-old profile, and the client's features silently vanish. This is the
685    /// test that fails if someone "simplifies" `negotiate_server` to always
686    /// return the earliest or the latest.
687    #[test]
688    fn a_supported_request_is_echoed_not_replaced() {
689        let supported = all();
690        // Every revision the server speaks comes back as itself — including the
691        // newest, which an "always answer latest" implementation gets right by
692        // accident, and the oldest, which an "always answer oldest" one gets
693        // right by accident. Neither would survive the middle rows.
694        for version in ProtocolVersion::KNOWN {
695            assert_eq!(
696                negotiate_server(version, &supported),
697                ServerDecision::Echoed(version),
698                "{version} was not echoed",
699            );
700        }
701    }
702
703    /// The same property, from the other end: the answer is never a revision
704    /// the client did not ask for *unless* the client asked for something we
705    /// cannot speak.
706    #[test]
707    fn the_answer_is_always_one_the_server_speaks() {
708        let supported = all();
709        for offered in ProtocolVersion::KNOWN {
710            let answer = negotiate_server(offered, &supported).version();
711            assert!(supported.contains(&answer), "{offered} -> {answer}");
712        }
713        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
714        assert_eq!(
715            negotiate_server(future, &supported),
716            ServerDecision::CounterOffered {
717                offered: future,
718                // Not `2026-07-28`: SEP-2575 removed `initialize` from that
719                // revision, so counter-offering it would send the peer to a
720                // protocol on which the handshake it just attempted does not
721                // exist.
722                answer: ProtocolVersion::V2025_11_25,
723            },
724        );
725    }
726
727    /// Scenario: echo-supported.
728    ///
729    /// A `2025-03-26` client gets `2025-03-26` back, and a profile that
730    /// excludes everything added later.
731    #[test]
732    fn echo_supported_scenario() {
733        let supported = all();
734        let decision = negotiate_server(ProtocolVersion::V2025_03_26, &supported);
735        assert_eq!(
736            decision,
737            ServerDecision::Echoed(ProtocolVersion::V2025_03_26)
738        );
739
740        let caps = CapabilitySet::all_for(ProtocolVersion::V2025_03_26);
741        let session = NegotiatedSession::new(decision.version(), TransportKind::Stdio, caps, caps);
742        assert_eq!(session.version, ProtocolVersion::V2025_03_26);
743        assert!(!session.allows(Feature::Elicitation), "added 2025-06-18");
744        assert!(
745            !session.allows(Feature::ToolStructuredOutput),
746            "added 2025-06-18"
747        );
748        assert!(!session.allows(Feature::Tasks), "added 2025-11-25");
749        // And it does have what `2025-03-26` defined.
750        assert!(session.allows(Feature::ToolAnnotations));
751        assert!(session.allows(Feature::Completions));
752    }
753
754    /// Scenario: unknown-version. No error, an answer.
755    #[test]
756    fn unknown_version_scenario() {
757        let supported = all();
758        for raw in ["2099-01-01", "2030-06-15", "not-a-date-at-all"] {
759            let offered = if raw == "not-a-date-at-all" {
760                ProtocolVersion::unknown_after_known("2027-12-31".parse().unwrap()).unwrap()
761            } else {
762                ProtocolVersion::unknown_after_known(raw.parse().unwrap()).unwrap()
763            };
764            let decision = negotiate_server(offered, &supported);
765            assert!(decision.is_counter_offer(), "{raw} should counter-offer");
766            assert_eq!(decision.version(), ProtocolVersion::V2025_11_25);
767        }
768    }
769
770    /// Scenario: stateless-discover. A `2026-07-28` request with no prior
771    /// initialize gets a per-request profile, and nothing is retained.
772    #[test]
773    fn stateless_discover_scenario() {
774        let version = ProtocolVersion::V2026_07_28;
775        let client_caps = CapabilitySet::all_for(version);
776        let server_caps = CapabilitySet::all_for(version);
777
778        let session = negotiate_stateless(
779            &all(),
780            server_caps,
781            TransportKind::Stdio,
782            StatelessRequest::empty()
783                .with_version(version)
784                .with_client_caps(client_caps),
785        )
786        .unwrap();
787
788        assert!(session.is_stateless, "2026-07-28 is stateless");
789        assert_eq!(session.version, version);
790        assert!(session.allows(Feature::ServerDiscover));
791        assert!(session.allows(Feature::Tools));
792        // The stateless lifecycle's own feature is on.
793        assert!(session.allows(Feature::StatelessLifecycle));
794    }
795
796    /// Two stateless requests are independent. Nothing is retained, so the
797    /// second one's profile is a function of the second request alone.
798    #[test]
799    fn stateless_requests_do_not_bleed_into_each_other() {
800        let server_caps = CapabilitySet::all();
801        let newer = negotiate_stateless(
802            &all(),
803            server_caps,
804            TransportKind::Stdio,
805            StatelessRequest::empty()
806                .with_version(ProtocolVersion::V2026_07_28)
807                .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
808        )
809        .unwrap();
810        let older = negotiate_stateless(
811            &all(),
812            server_caps,
813            TransportKind::Stdio,
814            StatelessRequest::empty()
815                .with_version(ProtocolVersion::V2024_11_05)
816                .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2024_11_05)),
817        )
818        .unwrap();
819        assert!(newer.allows(Feature::ServerDiscover));
820        assert!(!older.allows(Feature::ServerDiscover));
821        assert!(newer.len() > older.len());
822    }
823
824    #[test]
825    fn a_stateless_request_with_no_version_is_rejected() {
826        let error = negotiate_stateless(
827            &all(),
828            CapabilitySet::all(),
829            TransportKind::Stdio,
830            StatelessRequest::empty(),
831        )
832        .unwrap_err();
833        assert!(matches!(
834            error,
835            NegotiationError::MissingStatelessVersion { .. }
836        ));
837    }
838
839    /// A stateless request carrying a newer revision's capabilities cannot
840    /// switch on a feature the agreed revision cannot express.
841    #[test]
842    fn stateless_caps_are_restricted_to_the_agreed_revision() {
843        let session = negotiate_stateless(
844            &all(),
845            CapabilitySet::all(),
846            TransportKind::Stdio,
847            StatelessRequest::empty()
848                .with_version(ProtocolVersion::V2024_11_05)
849                .with_client_caps(CapabilitySet::all_for(ProtocolVersion::V2026_07_28)),
850        )
851        .unwrap();
852        assert!(!session.client_caps.contains(Capability::Elicitation));
853        assert!(!session.client_caps.contains(Capability::Extensions));
854        assert!(session.client_caps.contains(Capability::Sampling));
855        assert!(!session.allows(Feature::Elicitation));
856    }
857
858    /// The full (offered x supported) table, as the task asks.
859    #[test]
860    fn the_whole_offered_by_supported_table() {
861        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
862        let offered_by =
863            |versions: &[ProtocolVersion]| -> Vec<ProtocolVersion> { versions.to_vec() };
864        let _ = offered_by;
865
866        for supported_slice in [
867            vec![ProtocolVersion::V2024_11_05],
868            vec![ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_06_18],
869            vec![ProtocolVersion::V2025_11_25, ProtocolVersion::V2026_07_28],
870            ProtocolVersion::KNOWN.to_vec(),
871        ] {
872            let supported = subset(&supported_slice);
873            let mut offered = ProtocolVersion::KNOWN.to_vec();
874            offered.push(future);
875
876            for offered_version in offered {
877                let decision = negotiate_server(offered_version, &supported);
878                let expected_echo = supported.contains(&offered_version);
879                assert_eq!(
880                    decision.is_counter_offer(),
881                    !expected_echo,
882                    "{offered_version} against {supported_slice:?}",
883                );
884                // Whatever happened, the answer is the offered version or the
885                // server's latest, and always something the server speaks.
886                let answer = decision.version();
887                assert!(supported.contains(&answer), "{offered_version} -> {answer}");
888                if !expected_echo {
889                    assert_eq!(answer, supported.latest_handshake());
890                }
891            }
892        }
893    }
894
895    #[test]
896    fn the_client_accepts_a_version_it_speaks() {
897        let supported = all();
898        for answered in ProtocolVersion::KNOWN {
899            let accepted = negotiate_client(ProtocolVersion::V2026_07_28, answered, &supported);
900            assert_eq!(accepted.unwrap(), answered);
901        }
902    }
903
904    #[test]
905    fn the_client_rejects_a_version_it_does_not_speak() {
906        // A server that only speaks 2024 cannot be talked to by a client that
907        // only speaks 2026.
908        let client = subset(&[ProtocolVersion::V2026_07_28]);
909        let error = negotiate_client(
910            ProtocolVersion::V2026_07_28,
911            ProtocolVersion::V2024_11_05,
912            &client,
913        )
914        .unwrap_err();
915        match error {
916            NegotiationError::UnsupportedServerVersion { answered, .. } => {
917                assert_eq!(answered, ProtocolVersion::V2024_11_05);
918            }
919            other => panic!("expected UnsupportedServerVersion, got {other:?}"),
920        }
921        // And the error names what the client can speak, which is the only
922        // thing the operator can act on.
923        let rendered = negotiate_client(
924            ProtocolVersion::V2026_07_28,
925            ProtocolVersion::V2024_11_05,
926            &client,
927        )
928        .unwrap_err()
929        .to_string();
930        assert!(rendered.contains("2026-07-28"), "{rendered}");
931    }
932
933    /// A future version answered by a server is a rejection, because the client
934    /// cannot parse it — the mirror image of the server's counter-offer.
935    #[test]
936    fn a_client_cannot_accept_a_future_answer() {
937        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
938        assert!(negotiate_client(ProtocolVersion::V2026_07_28, future, &all()).is_err());
939    }
940
941    #[test]
942    fn the_header_is_checked_not_reconciled() {
943        let negotiated = ProtocolVersion::V2025_06_18;
944        assert!(check_protocol_header(negotiated, Some("2025-06-18")).is_ok());
945        // Surrounding whitespace is a transport detail, not a disagreement.
946        assert!(check_protocol_header(negotiated, Some("  2025-06-18  ")).is_ok());
947
948        for wrong in ["2024-11-05", "2026-07-28", "2025-03-26"] {
949            assert!(
950                check_protocol_header(negotiated, Some(wrong)).is_err(),
951                "{wrong} should not be accepted"
952            );
953        }
954    }
955
956    #[test]
957    fn a_missing_header_is_not_a_disagreement() {
958        let negotiated = ProtocolVersion::V2025_06_18;
959        assert!(check_protocol_header(negotiated, None).is_ok());
960    }
961
962    #[test]
963    fn a_malformed_header_is_distinct_from_a_mismatch() {
964        let error =
965            check_protocol_header(ProtocolVersion::V2025_06_18, Some("banana")).unwrap_err();
966        assert!(
967            matches!(error, NegotiationError::HeaderNotAVersion { .. }),
968            "{error:?}"
969        );
970        let error =
971            check_protocol_header(ProtocolVersion::V2025_06_18, Some("DRAFT-2025-v3")).unwrap_err();
972        assert!(
973            matches!(error, NegotiationError::HeaderNotAVersion { .. }),
974            "{error:?}",
975        );
976    }
977
978    /// The fallback is `2025-03-26`, not the oldest revision. Getting this
979    /// wrong is rmcp #852 in a different disguise.
980    #[test]
981    fn the_fallback_is_not_the_oldest_revision() {
982        assert_eq!(
983            assumed_version_without_signal(),
984            ProtocolVersion::V2025_03_26
985        );
986        assert_ne!(
987            assumed_version_without_signal(),
988            ProtocolVersion::earliest_known()
989        );
990        assert_eq!(assumed_version(None, None), ProtocolVersion::V2025_03_26);
991    }
992
993    #[test]
994    fn a_signal_beats_the_header_and_both_beat_the_fallback() {
995        assert_eq!(
996            assumed_version(Some("2024-11-05"), Some(ProtocolVersion::V2026_07_28)),
997            ProtocolVersion::V2026_07_28,
998        );
999        assert_eq!(
1000            assumed_version(Some("2025-11-25"), None),
1001            ProtocolVersion::V2025_11_25,
1002        );
1003        // A header that does not parse falls through to the fallback rather
1004        // than propagating a parse failure: the caller has already been told
1005        // about the malformed header by `check_protocol_header`.
1006        assert_eq!(
1007            assumed_version(Some("garbage"), None),
1008            ProtocolVersion::V2025_03_26
1009        );
1010        // A future version in the header is still a version.
1011        let future = ProtocolVersion::unknown_after_known("2099-01-01".parse().unwrap()).unwrap();
1012        assert_eq!(assumed_version(Some("2099-01-01"), None), future);
1013    }
1014
1015    /// `NegotiatedSession::is_stateless` tracks the lifecycle, not the caller.
1016    #[test]
1017    fn statelessness_follows_the_revision() {
1018        for version in ProtocolVersion::KNOWN {
1019            let session = NegotiatedSession::new(
1020                version,
1021                TransportKind::Stdio,
1022                CapabilitySet::all(),
1023                CapabilitySet::all(),
1024            );
1025            assert_eq!(
1026                session.is_stateless,
1027                version >= ProtocolVersion::V2026_07_28,
1028                "{version}"
1029            );
1030        }
1031    }
1032
1033    #[test]
1034    fn the_reserved_meta_keys_are_the_documented_ones() {
1035        for key in [
1036            stateless_meta_keys::PROTOCOL_VERSION,
1037            stateless_meta_keys::CLIENT_CAPABILITIES,
1038            stateless_meta_keys::CLIENT_INFO,
1039            stateless_meta_keys::SERVER_INFO,
1040        ] {
1041            assert!(is_reserved_meta_key(key), "{key}");
1042        }
1043        assert!(!is_reserved_meta_key(
1044            "io.modelcontextprotocol/subscriptionId"
1045        ));
1046        assert!(!is_reserved_meta_key("progressToken"));
1047        // The spellings are the contract, so they are asserted literally.
1048        assert_eq!(
1049            stateless_meta_keys::PROTOCOL_VERSION,
1050            "io.modelcontextprotocol/protocolVersion",
1051        );
1052    }
1053
1054    #[test]
1055    fn identity_is_carried_but_not_required() {
1056        let session = NegotiatedSession::new(
1057            ProtocolVersion::V2025_06_18,
1058            TransportKind::Stdio,
1059            CapabilitySet::all(),
1060            CapabilitySet::all(),
1061        );
1062        assert!(session.client_info.is_none());
1063        let profile = session.profile;
1064        let named = session
1065            .with_client_info(super::PeerInfo::new("test-client", "1.0"))
1066            .with_server_info(super::PeerInfo::new("protocol-droid", "0.1"));
1067        assert_eq!(named.client_info.as_ref().unwrap().name, "test-client");
1068        assert_eq!(named.server_info.as_ref().unwrap().name, "protocol-droid");
1069        // Identity does not change what the session may do.
1070        assert_eq!(named.profile, profile);
1071    }
1072}