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}