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}