Skip to main content

fastmcp_protocol/
protocol_policy.rs

1//! Exact MCP protocol-version identity and immutable era-policy selection.
2//!
3//! This module intentionally models only the two supported protocol eras. It
4//! does not normalize date strings or treat intermediate protocol revisions as
5//! aliases. The crate-root integration is owned separately so that this policy
6//! surface can remain independent of transport classification and lifecycle
7//! state.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12
13use fastmcp_core::CanonicalHttpUrl;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16/// The exact modern MCP protocol revision supported by this policy surface.
17pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
18
19/// The exact legacy MCP protocol revision supported by this policy surface.
20pub const LEGACY_PROTOCOL_VERSION: &str = "2024-11-05";
21
22/// A protocol era selected from one exact, supported protocol revision.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub enum ProtocolEra {
25    /// MCP 2026-07-28 per-request metadata semantics.
26    Modern2026,
27    /// MCP 2024-11-05 initialize-handshake semantics.
28    Legacy2024,
29}
30
31impl ProtocolEra {
32    /// Returns this era's only supported wire version.
33    #[must_use]
34    pub const fn version(self) -> ProtocolVersion {
35        match self {
36            Self::Modern2026 => ProtocolVersion::MODERN_2026,
37            Self::Legacy2024 => ProtocolVersion::LEGACY_2024,
38        }
39    }
40}
41
42/// An exact, supported MCP protocol-version value.
43///
44/// This type can contain only the two revisions defined by this module.
45/// Callers must retain an unsupported input separately after
46/// [`ProtocolVersion::parse`] rejects it; in particular, `2025-11-25` cannot
47/// be parsed, aliased, or normalized into either supported era.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub struct ProtocolVersion(ProtocolEra);
50
51impl ProtocolVersion {
52    /// The exact MCP 2026-07-28 protocol version.
53    pub const MODERN_2026: Self = Self(ProtocolEra::Modern2026);
54
55    /// The exact MCP 2024-11-05 protocol version.
56    pub const LEGACY_2024: Self = Self(ProtocolEra::Legacy2024);
57
58    /// Parses one exact supported protocol-version string.
59    pub fn parse(value: &str) -> Result<Self, ProtocolVersionError> {
60        match value {
61            MODERN_PROTOCOL_VERSION => Ok(Self::MODERN_2026),
62            LEGACY_PROTOCOL_VERSION => Ok(Self::LEGACY_2024),
63            _ => Err(ProtocolVersionError::UnsupportedVersion {
64                received: value.to_owned(),
65            }),
66        }
67    }
68
69    /// Returns the exact protocol-version spelling.
70    #[must_use]
71    pub const fn as_str(self) -> &'static str {
72        match self.0 {
73            ProtocolEra::Modern2026 => MODERN_PROTOCOL_VERSION,
74            ProtocolEra::Legacy2024 => LEGACY_PROTOCOL_VERSION,
75        }
76    }
77
78    /// Returns the era identified by this exact version.
79    #[must_use]
80    pub const fn era(self) -> ProtocolEra {
81        self.0
82    }
83}
84
85impl fmt::Display for ProtocolVersion {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        formatter.write_str(self.as_str())
88    }
89}
90
91impl FromStr for ProtocolVersion {
92    type Err = ProtocolVersionError;
93
94    fn from_str(value: &str) -> Result<Self, Self::Err> {
95        Self::parse(value)
96    }
97}
98
99impl Serialize for ProtocolVersion {
100    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
101    where
102        S: Serializer,
103    {
104        serializer.serialize_str(self.as_str())
105    }
106}
107
108impl<'de> Deserialize<'de> for ProtocolVersion {
109    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110    where
111        D: Deserializer<'de>,
112    {
113        let value = String::deserialize(deserializer)?;
114        Self::parse(&value).map_err(serde::de::Error::custom)
115    }
116}
117
118/// Typed refusal for an unsupported protocol-version spelling.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ProtocolVersionError {
121    /// The string is not one of FastMCP's two exact supported revisions.
122    UnsupportedVersion {
123        /// The input spelling, retained without normalization.
124        received: String,
125    },
126}
127
128impl fmt::Display for ProtocolVersionError {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            Self::UnsupportedVersion { received } => {
132                write!(formatter, "unsupported MCP protocol version {received:?}")
133            }
134        }
135    }
136}
137
138impl std::error::Error for ProtocolVersionError {}
139
140/// Immutable policy chosen before a client connects or a server binds.
141///
142/// `Auto` remains the default policy. A policy value does not itself classify
143/// a peer or mutate in response to peer bytes, errors, timeouts, or auth
144/// events; transport-specific classification belongs to FND-03 integration.
145#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub enum ProtocolPolicy {
147    /// Permit both supported eras; transport-specific code classifies once.
148    #[default]
149    Auto,
150    /// Permit only MCP 2026-07-28.
151    ModernOnly,
152    /// Permit only exact MCP 2024-11-05.
153    LegacyOnly,
154}
155
156const MODERN_ONLY_VERSIONS: [ProtocolVersion; 1] = [ProtocolVersion::MODERN_2026];
157const LEGACY_ONLY_VERSIONS: [ProtocolVersion; 1] = [ProtocolVersion::LEGACY_2024];
158const AUTO_SUPPORTED_VERSIONS: [ProtocolVersion; 2] =
159    [ProtocolVersion::MODERN_2026, ProtocolVersion::LEGACY_2024];
160const NO_MODERN_DISCOVERY_VERSIONS: [ProtocolVersion; 0] = [];
161
162impl ProtocolPolicy {
163    const fn display_name(self) -> &'static str {
164        match self {
165            Self::Auto => "auto",
166            Self::ModernOnly => "modern-only",
167            Self::LegacyOnly => "legacy-only",
168        }
169    }
170
171    /// Returns whether this immutable policy admits a version.
172    #[must_use]
173    pub const fn permits(self, version: ProtocolVersion) -> bool {
174        match self {
175            Self::Auto => true,
176            Self::ModernOnly => matches!(version.era(), ProtocolEra::Modern2026),
177            Self::LegacyOnly => matches!(version.era(), ProtocolEra::Legacy2024),
178        }
179    }
180
181    /// Returns the policy-level exact supported-version set.
182    ///
183    /// This answers which eras the selected policy can operate, not which
184    /// versions a modern Streamable HTTP discovery response advertises. In
185    /// particular, `Auto` retains both eras here so its legacy adapter path
186    /// remains available after isolated transport classification.
187    #[must_use]
188    pub const fn supported_versions(self) -> &'static [ProtocolVersion] {
189        match self {
190            Self::Auto => &AUTO_SUPPORTED_VERSIONS,
191            Self::ModernOnly => &MODERN_ONLY_VERSIONS,
192            Self::LegacyOnly => &LEGACY_ONLY_VERSIONS,
193        }
194    }
195
196    /// Returns the versions advertised by modern Streamable HTTP discovery.
197    ///
198    /// Legacy `2024-11-05` is selected only through the isolated legacy
199    /// transport path. It is never included inline in a modern discovery
200    /// response, including when the immutable policy is `Auto`.
201    #[must_use]
202    pub const fn modern_discovery_versions(self) -> &'static [ProtocolVersion] {
203        match self {
204            Self::Auto | Self::ModernOnly => &MODERN_ONLY_VERSIONS,
205            Self::LegacyOnly => &NO_MODERN_DISCOVERY_VERSIONS,
206        }
207    }
208
209    /// Returns the client preference order for this policy.
210    ///
211    /// Auto prefers the modern revision first; a fallback decision is owned by
212    /// the isolated client-negotiation layer, never by this value type.
213    #[must_use]
214    pub const fn preferred_versions(self) -> &'static [ProtocolVersion] {
215        self.supported_versions()
216    }
217
218    /// Returns whether using this policy requires a legacy adapter receipt.
219    #[must_use]
220    pub const fn requires_legacy_adapter(self) -> bool {
221        !matches!(self, Self::ModernOnly)
222    }
223
224    /// Validates a client policy before any connect-side effect.
225    pub fn validate_for_client(
226        self,
227        legacy_receipt: Option<&LegacyClientAdapterInstalledReceipt>,
228    ) -> Result<ProtocolPolicySelection, ProtocolPolicyError> {
229        self.validate(
230            ProtocolRole::Client,
231            legacy_receipt.map(LegacyReceipt::Client),
232        )
233    }
234
235    /// Validates a server policy before any bind-side effect.
236    pub fn validate_for_server(
237        self,
238        legacy_receipt: Option<&LegacyServerAdapterInstalledReceipt>,
239    ) -> Result<ProtocolPolicySelection, ProtocolPolicyError> {
240        self.validate(
241            ProtocolRole::Server,
242            legacy_receipt.map(LegacyReceipt::Server),
243        )
244    }
245
246    fn validate(
247        self,
248        role: ProtocolRole,
249        legacy_receipt: Option<LegacyReceipt<'_>>,
250    ) -> Result<ProtocolPolicySelection, ProtocolPolicyError> {
251        if !self.requires_legacy_adapter() {
252            return Ok(ProtocolPolicySelection { policy: self, role });
253        }
254
255        let Some(receipt) = legacy_receipt else {
256            return Err(ProtocolPolicyError::FeatureUnavailable { policy: self, role });
257        };
258
259        if receipt.policy() != self {
260            return Err(ProtocolPolicyError::ReceiptPolicyMismatch {
261                policy: self,
262                receipt_policy: receipt.policy(),
263                role,
264            });
265        }
266
267        Ok(ProtocolPolicySelection { policy: self, role })
268    }
269}
270
271/// The role for which an immutable policy is validated.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
273pub enum ProtocolRole {
274    /// A client policy before connect.
275    Client,
276    /// A server policy before bind.
277    Server,
278}
279
280/// A validated immutable policy and role.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub struct ProtocolPolicySelection {
283    policy: ProtocolPolicy,
284    role: ProtocolRole,
285}
286
287impl ProtocolPolicySelection {
288    /// Returns the selected immutable policy.
289    #[must_use]
290    pub const fn policy(self) -> ProtocolPolicy {
291        self.policy
292    }
293
294    /// Returns the role for which the policy was validated.
295    #[must_use]
296    pub const fn role(self) -> ProtocolRole {
297        self.role
298    }
299}
300
301/// Typed policy-validation refusals raised before connect or bind side effects.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum ProtocolPolicyError {
304    /// A legacy-capable policy was selected without its required feature and
305    /// sealed adapter-installation receipt.
306    FeatureUnavailable {
307        /// The rejected policy.
308        policy: ProtocolPolicy,
309        /// The role whose adapter receipt was required.
310        role: ProtocolRole,
311    },
312    /// A sealed receipt was installed for a different immutable policy.
313    ReceiptPolicyMismatch {
314        /// The selected policy.
315        policy: ProtocolPolicy,
316        /// The policy to which the receipt is bound.
317        receipt_policy: ProtocolPolicy,
318        /// The role whose receipt was supplied.
319        role: ProtocolRole,
320    },
321}
322
323impl fmt::Display for ProtocolPolicyError {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            Self::FeatureUnavailable { policy, role } => {
327                write!(
328                    formatter,
329                    "{policy:?} is unavailable for {role:?} without a legacy adapter receipt"
330                )
331            }
332            Self::ReceiptPolicyMismatch {
333                policy,
334                receipt_policy,
335                role,
336            } => write!(
337                formatter,
338                "{role:?} legacy receipt is bound to {receipt_policy:?}, not {policy:?}"
339            ),
340        }
341    }
342}
343
344impl std::error::Error for ProtocolPolicyError {}
345
346/// Immutable facts bound into a sealed legacy-adapter installation receipt.
347///
348/// Only the exact adapter installers can supply this value to the sealed
349/// issuer. It deliberately has no serializer or public constructor.
350#[derive(Debug, PartialEq, Eq)]
351pub struct LegacyReceiptBinding {
352    policy: ProtocolPolicy,
353    transport_binding: String,
354    endpoint_or_process_configuration: String,
355    security_partition: String,
356    adapter_generation: u64,
357    store_generation: u64,
358    configuration_generation: u64,
359    limits_profile_identity: String,
360}
361
362impl LegacyReceiptBinding {
363    /// Creates a binding only from trusted in-crate adapter installation code.
364    #[allow(clippy::too_many_arguments)]
365    pub(crate) fn new(
366        policy: ProtocolPolicy,
367        transport_binding: String,
368        endpoint_or_process_configuration: String,
369        security_partition: String,
370        adapter_generation: u64,
371        store_generation: u64,
372        configuration_generation: u64,
373        limits_profile_identity: String,
374    ) -> Self {
375        Self {
376            policy,
377            transport_binding,
378            endpoint_or_process_configuration,
379            security_partition,
380            adapter_generation,
381            store_generation,
382            configuration_generation,
383            limits_profile_identity,
384        }
385    }
386}
387
388/// Opaque, non-cloneable proof that the exact legacy client adapter is installed.
389#[derive(Debug, PartialEq, Eq)]
390pub struct LegacyClientAdapterInstalledReceipt {
391    binding: LegacyReceiptBinding,
392}
393
394/// Opaque, non-cloneable proof that the exact legacy server adapter is installed.
395#[derive(Debug, PartialEq, Eq)]
396pub struct LegacyServerAdapterInstalledReceipt {
397    binding: LegacyReceiptBinding,
398}
399
400impl LegacyClientAdapterInstalledReceipt {
401    /// Returns the immutable policy bound at client-adapter installation.
402    #[must_use]
403    pub const fn policy(&self) -> ProtocolPolicy {
404        self.binding.policy
405    }
406}
407
408impl LegacyServerAdapterInstalledReceipt {
409    /// Returns the immutable policy bound at server-adapter installation.
410    #[must_use]
411    pub const fn policy(&self) -> ProtocolPolicy {
412        self.binding.policy
413    }
414}
415
416enum LegacyReceipt<'a> {
417    Client(&'a LegacyClientAdapterInstalledReceipt),
418    Server(&'a LegacyServerAdapterInstalledReceipt),
419}
420
421impl LegacyReceipt<'_> {
422    const fn policy(&self) -> ProtocolPolicy {
423        match self {
424            Self::Client(receipt) => receipt.policy(),
425            Self::Server(receipt) => receipt.policy(),
426        }
427    }
428}
429
430mod sealed {
431    pub trait ReceiptIssuerSealed {}
432}
433
434/// Sealed issuer interface for the exact legacy-adapter installation path.
435///
436/// External crates cannot implement this trait and therefore cannot forge a
437/// client or server installation receipt. The future LEG-02 and LEG-03
438/// installers are the only intended in-crate implementers.
439#[allow(private_bounds)]
440pub trait LegacyAdapterReceiptIssuer: sealed::ReceiptIssuerSealed {
441    /// Issues a client receipt from trusted installation facts.
442    #[doc(hidden)]
443    fn issue_client_receipt(binding: LegacyReceiptBinding) -> LegacyClientAdapterInstalledReceipt {
444        LegacyClientAdapterInstalledReceipt { binding }
445    }
446
447    /// Issues a server receipt from trusted installation facts.
448    #[doc(hidden)]
449    fn issue_server_receipt(binding: LegacyReceiptBinding) -> LegacyServerAdapterInstalledReceipt {
450        LegacyServerAdapterInstalledReceipt { binding }
451    }
452}
453
454/// The state of one server-side stdio process's immutable era selection.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub enum StdioEraState {
457    /// Auto policy has not yet received a structurally valid opening request.
458    Unclassified,
459    /// The process selected this era and cannot select again.
460    Selected(ProtocolEra),
461    /// An invalid opening closed the process without selecting an era.
462    TerminalWithoutEra,
463}
464
465/// The opening frame relevant to stdio era selection.
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum StdioOpeningFrame {
468    /// A complete modern request carrying a protocol-version field.
469    ModernRequest {
470        /// The exact protocol-version spelling supplied by the peer.
471        protocol_version: String,
472    },
473    /// Exact legacy `initialize` with no modern marker.
474    LegacyInitialize,
475    /// An otherwise modern request that also contains an initialize marker.
476    MixedInitializeAndModernMetadata {
477        /// The exact protocol-version spelling supplied by the peer.
478        protocol_version: String,
479    },
480    /// A request that provides no complete modern metadata.
481    RequestWithoutModernMetadata,
482    /// A notification, for which no JSON-RPC response may be emitted.
483    Notification,
484    /// A JSON-RPC response received where an opening request is required.
485    Response,
486    /// A malformed opening frame.
487    Malformed,
488}
489
490/// Modern-version support result after era selection.
491#[derive(Debug, Clone, PartialEq, Eq)]
492pub enum ModernVersionSupport {
493    /// The modern metadata contained exact MCP 2026-07-28.
494    Supported,
495    /// The metadata was structurally valid but its version is unsupported.
496    Unsupported {
497        /// The unnormalized version spelling supplied by the peer.
498        received: String,
499    },
500}
501
502/// Deterministic result of considering one stdio frame.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum StdioEraDecision {
505    /// The process selected an era exactly once.
506    Selected {
507        /// The immutable selected era.
508        era: ProtocolEra,
509        /// Present only for modern request metadata.
510        modern_version: Option<ModernVersionSupport>,
511    },
512    /// A frame was rejected under an already selected fixed policy or era.
513    RejectedUnderSelectedEra {
514        /// The fixed or previously selected era.
515        era: ProtocolEra,
516        /// The exact rejection class.
517        reason: StdioEraRejection,
518    },
519    /// The first frame was rejected and the process closed without an era.
520    RejectedAndClosed {
521        /// The exact rejection class.
522        reason: StdioEraRejection,
523    },
524    /// A terminal process ignores any attempt to retry classification.
525    AlreadyTerminal,
526}
527
528/// Typed refusal classes for invalid stdio era-selection traffic.
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530pub enum StdioEraRejection {
531    /// Legacy initialize and modern metadata were mixed in one opening frame.
532    MixedEraMarkers,
533    /// A request omitted complete modern metadata where it was required.
534    MissingModernMetadata,
535    /// A notification arrived where an opening request is required.
536    NotificationCannotClassify,
537    /// A response arrived where an opening request is required.
538    ResponseCannotClassify,
539    /// The opening bytes were malformed.
540    MalformedOpeningFrame,
541    /// Legacy-only policy requires exact legacy initialize as the first frame.
542    LegacyInitializeRequired,
543    /// Traffic from the opposite era arrived after selection.
544    CrossEraTraffic,
545}
546
547/// One-shot era classifier owned by a single server-side stdio process.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct StdioEraClassifier {
550    policy: ProtocolPolicy,
551    state: StdioEraState,
552}
553
554impl StdioEraClassifier {
555    /// Creates a classifier with its policy fixed before the first frame.
556    #[must_use]
557    pub const fn new(policy: ProtocolPolicy) -> Self {
558        let state = match policy {
559            ProtocolPolicy::Auto => StdioEraState::Unclassified,
560            ProtocolPolicy::ModernOnly => StdioEraState::Selected(ProtocolEra::Modern2026),
561            ProtocolPolicy::LegacyOnly => StdioEraState::Selected(ProtocolEra::Legacy2024),
562        };
563        Self { policy, state }
564    }
565
566    /// Returns the policy fixed for this process.
567    #[must_use]
568    pub const fn policy(&self) -> ProtocolPolicy {
569        self.policy
570    }
571
572    /// Returns the process's current one-shot selection state.
573    #[must_use]
574    pub const fn state(&self) -> &StdioEraState {
575        &self.state
576    }
577
578    /// Considers the opening frame without allowing same-process retry.
579    pub fn classify_opening(&mut self, frame: StdioOpeningFrame) -> StdioEraDecision {
580        match self.state {
581            StdioEraState::TerminalWithoutEra => StdioEraDecision::AlreadyTerminal,
582            StdioEraState::Unclassified => self.classify_auto(frame),
583            StdioEraState::Selected(era) => self.classify_fixed(era, frame),
584        }
585    }
586
587    fn classify_auto(&mut self, frame: StdioOpeningFrame) -> StdioEraDecision {
588        match frame {
589            StdioOpeningFrame::ModernRequest { protocol_version } => {
590                self.select_modern(protocol_version)
591            }
592            StdioOpeningFrame::LegacyInitialize => {
593                self.state = StdioEraState::Selected(ProtocolEra::Legacy2024);
594                StdioEraDecision::Selected {
595                    era: ProtocolEra::Legacy2024,
596                    modern_version: None,
597                }
598            }
599            StdioOpeningFrame::MixedInitializeAndModernMetadata { .. } => {
600                self.reject_and_close(StdioEraRejection::MixedEraMarkers)
601            }
602            StdioOpeningFrame::RequestWithoutModernMetadata => {
603                // Auto: a first frame with no modern metadata is implicit
604                // exact-2024 traffic, not a close. JSON-RPC clients (and
605                // Agent Mail protocol_compliance) may send tools/list or an
606                // unknown method before initialize; that must stay
607                // MethodNotFound / dispatch, not InvalidRequest+close.
608                self.state = StdioEraState::Selected(ProtocolEra::Legacy2024);
609                StdioEraDecision::Selected {
610                    era: ProtocolEra::Legacy2024,
611                    modern_version: None,
612                }
613            }
614            StdioOpeningFrame::Notification => {
615                self.reject_and_close(StdioEraRejection::NotificationCannotClassify)
616            }
617            StdioOpeningFrame::Response => {
618                self.reject_and_close(StdioEraRejection::ResponseCannotClassify)
619            }
620            StdioOpeningFrame::Malformed => {
621                self.reject_and_close(StdioEraRejection::MalformedOpeningFrame)
622            }
623        }
624    }
625
626    fn classify_fixed(&mut self, era: ProtocolEra, frame: StdioOpeningFrame) -> StdioEraDecision {
627        match (era, frame) {
628            (ProtocolEra::Modern2026, StdioOpeningFrame::ModernRequest { protocol_version }) => {
629                Self::modern_decision(protocol_version)
630            }
631            (ProtocolEra::Modern2026, StdioOpeningFrame::LegacyInitialize) => {
632                StdioEraDecision::RejectedUnderSelectedEra {
633                    era,
634                    reason: StdioEraRejection::CrossEraTraffic,
635                }
636            }
637            (
638                ProtocolEra::Modern2026,
639                StdioOpeningFrame::MixedInitializeAndModernMetadata { .. },
640            ) => StdioEraDecision::RejectedUnderSelectedEra {
641                era,
642                reason: StdioEraRejection::MixedEraMarkers,
643            },
644            (ProtocolEra::Modern2026, StdioOpeningFrame::RequestWithoutModernMetadata) => {
645                StdioEraDecision::RejectedUnderSelectedEra {
646                    era,
647                    reason: StdioEraRejection::MissingModernMetadata,
648                }
649            }
650            (ProtocolEra::Modern2026, StdioOpeningFrame::Notification) => {
651                StdioEraDecision::RejectedUnderSelectedEra {
652                    era,
653                    reason: StdioEraRejection::NotificationCannotClassify,
654                }
655            }
656            (ProtocolEra::Modern2026, StdioOpeningFrame::Response) => {
657                StdioEraDecision::RejectedUnderSelectedEra {
658                    era,
659                    reason: StdioEraRejection::ResponseCannotClassify,
660                }
661            }
662            (ProtocolEra::Modern2026, StdioOpeningFrame::Malformed) => {
663                StdioEraDecision::RejectedUnderSelectedEra {
664                    era,
665                    reason: StdioEraRejection::MalformedOpeningFrame,
666                }
667            }
668            (ProtocolEra::Legacy2024, StdioOpeningFrame::LegacyInitialize) => {
669                StdioEraDecision::Selected {
670                    era,
671                    modern_version: None,
672                }
673            }
674            (
675                ProtocolEra::Legacy2024,
676                StdioOpeningFrame::RequestWithoutModernMetadata | StdioOpeningFrame::Notification,
677            ) => StdioEraDecision::Selected {
678                era,
679                modern_version: None,
680            },
681            (
682                ProtocolEra::Legacy2024,
683                StdioOpeningFrame::ModernRequest { .. }
684                | StdioOpeningFrame::MixedInitializeAndModernMetadata { .. },
685            ) => StdioEraDecision::RejectedUnderSelectedEra {
686                era,
687                reason: StdioEraRejection::CrossEraTraffic,
688            },
689            (ProtocolEra::Legacy2024, _) => StdioEraDecision::RejectedUnderSelectedEra {
690                era,
691                reason: StdioEraRejection::LegacyInitializeRequired,
692            },
693        }
694    }
695
696    fn select_modern(&mut self, protocol_version: String) -> StdioEraDecision {
697        self.state = StdioEraState::Selected(ProtocolEra::Modern2026);
698        Self::modern_decision(protocol_version)
699    }
700
701    fn modern_decision(protocol_version: String) -> StdioEraDecision {
702        let modern_version = match ProtocolVersion::parse(&protocol_version) {
703            Ok(ProtocolVersion::MODERN_2026) => ModernVersionSupport::Supported,
704            Ok(ProtocolVersion::LEGACY_2024)
705            | Err(ProtocolVersionError::UnsupportedVersion { .. }) => {
706                ModernVersionSupport::Unsupported {
707                    received: protocol_version,
708                }
709            }
710        };
711        StdioEraDecision::Selected {
712            era: ProtocolEra::Modern2026,
713            modern_version: Some(modern_version),
714        }
715    }
716
717    fn reject_and_close(&mut self, reason: StdioEraRejection) -> StdioEraDecision {
718        self.state = StdioEraState::TerminalWithoutEra;
719        StdioEraDecision::RejectedAndClosed { reason }
720    }
721}
722
723/// One explicit configured HTTP route before protocol parsing.
724#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
725pub enum HttpRouteKind {
726    /// Modern Streamable HTTP MCP POST route.
727    ModernMcpPost,
728    /// Exact legacy SSE GET route.
729    LegacySseGet,
730    /// Exact legacy message POST route.
731    LegacyMessagePost,
732}
733
734impl HttpRouteKind {
735    const fn method(self) -> &'static str {
736        match self {
737            Self::ModernMcpPost | Self::LegacyMessagePost => "POST",
738            Self::LegacySseGet => "GET",
739        }
740    }
741
742    const fn display_name(self) -> &'static str {
743        match self {
744            Self::ModernMcpPost => "modern MCP POST",
745            Self::LegacySseGet => "legacy SSE GET",
746            Self::LegacyMessagePost => "legacy message POST",
747        }
748    }
749}
750
751impl fmt::Display for HttpRouteKind {
752    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
753        formatter.write_str(self.display_name())
754    }
755}
756
757/// Immutable, configured HTTP endpoint bundle for one policy selection.
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct HttpEndpointBundle {
760    key: HttpEndpointBundleKey,
761}
762
763/// Opaque key for HTTP era negotiation and cached classification state.
764///
765/// Equality includes complete canonical targets, including path and query, as
766/// well as the opaque partition/profile/generation values. Origin equality by
767/// itself is never bundle identity.
768#[derive(Debug, Clone, PartialEq, Eq, Hash)]
769pub struct HttpEndpointBundleKey {
770    modern_post_target: Option<String>,
771    legacy_sse_target: Option<String>,
772    legacy_message_post_target: Option<String>,
773    credential_partition: String,
774    security_partition: String,
775    transport_profile: String,
776    policy: ProtocolPolicy,
777    policy_generation: u64,
778    configuration_generation: u64,
779    legacy_receipt_generation: u64,
780}
781
782/// Typed refusal while constructing a configured HTTP endpoint bundle.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum HttpEndpointBundleError {
785    /// A policy requiring modern HTTP lacked its explicit configured POST target.
786    MissingModernPostTarget {
787        /// The rejected policy.
788        policy: ProtocolPolicy,
789    },
790    /// A policy requiring legacy HTTP lacked its configured SSE GET target.
791    MissingLegacySseTarget {
792        /// The rejected policy.
793        policy: ProtocolPolicy,
794    },
795    /// A policy requiring legacy HTTP lacked its configured message POST target.
796    MissingLegacyMessagePostTarget {
797        /// The rejected policy.
798        policy: ProtocolPolicy,
799    },
800    /// A configured endpoint contained a fragment, which is never sent in HTTP.
801    FragmentNotAllowed {
802        /// The route that supplied the invalid target.
803        route: HttpRouteKind,
804    },
805    /// Two configured routes have the same method and exact canonical target.
806    RouteCollision {
807        /// The first colliding route.
808        first: HttpRouteKind,
809        /// The second colliding route.
810        second: HttpRouteKind,
811        /// The colliding full canonical target.
812        target: String,
813    },
814}
815
816impl fmt::Display for HttpEndpointBundleError {
817    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
818        match self {
819            Self::MissingModernPostTarget { policy } => write!(
820                formatter,
821                "protocol policy {} requires a configured modern MCP POST target",
822                policy.display_name()
823            ),
824            Self::MissingLegacySseTarget { policy } => write!(
825                formatter,
826                "protocol policy {} requires a configured legacy SSE GET target",
827                policy.display_name()
828            ),
829            Self::MissingLegacyMessagePostTarget { policy } => write!(
830                formatter,
831                "protocol policy {} requires a configured legacy message POST target",
832                policy.display_name()
833            ),
834            Self::FragmentNotAllowed { route } => write!(
835                formatter,
836                "configured {route} target must not contain a fragment"
837            ),
838            Self::RouteCollision {
839                first,
840                second,
841                target,
842            } => write!(
843                formatter,
844                "configured {first} and {second} routes collide at {target}"
845            ),
846        }
847    }
848}
849
850impl std::error::Error for HttpEndpointBundleError {}
851
852impl HttpEndpointBundle {
853    /// Builds a trusted bundle exclusively from configured canonical targets.
854    #[allow(clippy::too_many_arguments)]
855    pub fn new(
856        policy: ProtocolPolicy,
857        modern_post: Option<CanonicalHttpUrl>,
858        legacy_sse: Option<CanonicalHttpUrl>,
859        legacy_message_post: Option<CanonicalHttpUrl>,
860        credential_partition: String,
861        security_partition: String,
862        transport_profile: String,
863        policy_generation: u64,
864        configuration_generation: u64,
865        legacy_receipt_generation: u64,
866    ) -> Result<Self, HttpEndpointBundleError> {
867        let requires_modern = !matches!(policy, ProtocolPolicy::LegacyOnly);
868        let requires_legacy = !matches!(policy, ProtocolPolicy::ModernOnly);
869
870        if requires_modern && modern_post.is_none() {
871            return Err(HttpEndpointBundleError::MissingModernPostTarget { policy });
872        }
873        if requires_legacy && legacy_sse.is_none() {
874            return Err(HttpEndpointBundleError::MissingLegacySseTarget { policy });
875        }
876        if requires_legacy && legacy_message_post.is_none() {
877            return Err(HttpEndpointBundleError::MissingLegacyMessagePostTarget { policy });
878        }
879
880        Self::reject_fragment(modern_post.as_ref(), HttpRouteKind::ModernMcpPost)?;
881        Self::reject_fragment(legacy_sse.as_ref(), HttpRouteKind::LegacySseGet)?;
882        Self::reject_fragment(
883            legacy_message_post.as_ref(),
884            HttpRouteKind::LegacyMessagePost,
885        )?;
886
887        let key = HttpEndpointBundleKey {
888            modern_post_target: modern_post.map(|target| target.as_str().to_owned()),
889            legacy_sse_target: legacy_sse.map(|target| target.as_str().to_owned()),
890            legacy_message_post_target: legacy_message_post
891                .map(|target| target.as_str().to_owned()),
892            credential_partition,
893            security_partition,
894            transport_profile,
895            policy,
896            policy_generation,
897            configuration_generation,
898            legacy_receipt_generation,
899        };
900        Self::reject_route_collisions(&key)?;
901        Ok(Self { key })
902    }
903
904    /// Returns the immutable opaque cache key for this configured bundle.
905    #[must_use]
906    pub fn key(&self) -> HttpEndpointBundleKey {
907        self.key.clone()
908    }
909
910    fn reject_fragment(
911        target: Option<&CanonicalHttpUrl>,
912        route: HttpRouteKind,
913    ) -> Result<(), HttpEndpointBundleError> {
914        if target.is_some_and(|target| target.fragment().is_some()) {
915            return Err(HttpEndpointBundleError::FragmentNotAllowed { route });
916        }
917        Ok(())
918    }
919
920    fn reject_route_collisions(key: &HttpEndpointBundleKey) -> Result<(), HttpEndpointBundleError> {
921        let routes = [
922            (
923                HttpRouteKind::ModernMcpPost,
924                key.modern_post_target.as_deref(),
925            ),
926            (
927                HttpRouteKind::LegacySseGet,
928                key.legacy_sse_target.as_deref(),
929            ),
930            (
931                HttpRouteKind::LegacyMessagePost,
932                key.legacy_message_post_target.as_deref(),
933            ),
934        ];
935        for (index, (first_kind, first_target)) in routes.iter().enumerate() {
936            let Some(first_target) = first_target else {
937                continue;
938            };
939            for (second_kind, second_target) in routes.iter().skip(index + 1) {
940                if first_kind.method() == second_kind.method()
941                    && second_target.is_some_and(|second_target| second_target == *first_target)
942                {
943                    return Err(HttpEndpointBundleError::RouteCollision {
944                        first: *first_kind,
945                        second: *second_kind,
946                        target: (*first_target).to_owned(),
947                    });
948                }
949            }
950        }
951        Ok(())
952    }
953}
954
955/// The body category returned by one isolated modern HTTP probe.
956#[derive(Debug, Clone, Copy, PartialEq, Eq)]
957pub enum HttpProbeBody {
958    /// A recognized modern JSON-RPC result or error object.
959    RecognizedModernJsonRpc,
960    /// An empty response body.
961    Empty,
962    /// A body that is not a recognized modern JSON-RPC result or error.
963    Unrecognized,
964    /// No response was received because of a transport failure.
965    TransportFailure,
966}
967
968/// One isolated response to the configured modern HTTP probe.
969#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub struct HttpModernProbe {
971    /// The received HTTP status, when a response was received.
972    pub status: u16,
973    /// The classified body form.
974    pub body: HttpProbeBody,
975}
976
977/// Era classification result for a configured HTTP bundle.
978#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979pub enum HttpEraDecision {
980    /// The bundle selected this era and may cache it under its exact key.
981    Selected(ProtocolEra),
982    /// The modern probe permits one configured legacy SSE observation.
983    ///
984    /// This is not an era selection and must never be cached. Only a later
985    /// validated legacy SSE endpoint event may select exact MCP 2024-11-05.
986    LegacySseFallbackAuthorized,
987    /// The response cannot signal downgrade and must not trigger legacy GET.
988    RejectedWithoutLegacyFallback,
989}
990
991/// Per-bundle era cache that cannot be contaminated by origin-only matches.
992#[derive(Debug, Default)]
993pub struct HttpEraCache {
994    selected_eras: HashMap<HttpEndpointBundleKey, ProtocolEra>,
995}
996
997impl HttpEraCache {
998    /// Classifies one configured bundle once or returns its immutable cached era.
999    pub fn classify_or_cached(
1000        &mut self,
1001        bundle: &HttpEndpointBundle,
1002        probe: HttpModernProbe,
1003    ) -> HttpEraDecision {
1004        let key = bundle.key();
1005        if let Some(era) = self.selected_eras.get(&key) {
1006            return HttpEraDecision::Selected(*era);
1007        }
1008
1009        let decision = Self::classify_probe(bundle.key.policy, probe);
1010        if let HttpEraDecision::Selected(era) = decision {
1011            self.selected_eras.insert(key, era);
1012        }
1013        decision
1014    }
1015
1016    /// Explicit trusted invalidation removes only one exact bundle key.
1017    pub fn invalidate(&mut self, key: &HttpEndpointBundleKey) -> Option<ProtocolEra> {
1018        self.selected_eras.remove(key)
1019    }
1020
1021    /// Returns an era only for the exact full bundle key.
1022    #[must_use]
1023    pub fn selected_era(&self, key: &HttpEndpointBundleKey) -> Option<ProtocolEra> {
1024        self.selected_eras.get(key).copied()
1025    }
1026
1027    fn classify_probe(policy: ProtocolPolicy, probe: HttpModernProbe) -> HttpEraDecision {
1028        match policy {
1029            ProtocolPolicy::ModernOnly
1030                if matches!(probe.body, HttpProbeBody::RecognizedModernJsonRpc) =>
1031            {
1032                HttpEraDecision::Selected(ProtocolEra::Modern2026)
1033            }
1034            ProtocolPolicy::ModernOnly => HttpEraDecision::RejectedWithoutLegacyFallback,
1035            ProtocolPolicy::LegacyOnly => HttpEraDecision::Selected(ProtocolEra::Legacy2024),
1036            ProtocolPolicy::Auto
1037                if matches!(probe.body, HttpProbeBody::RecognizedModernJsonRpc) =>
1038            {
1039                HttpEraDecision::Selected(ProtocolEra::Modern2026)
1040            }
1041            ProtocolPolicy::Auto
1042                if matches!(probe.status, 400 | 404 | 405)
1043                    && matches!(
1044                        probe.body,
1045                        HttpProbeBody::Empty | HttpProbeBody::Unrecognized
1046                    ) =>
1047            {
1048                HttpEraDecision::LegacySseFallbackAuthorized
1049            }
1050            ProtocolPolicy::Auto => HttpEraDecision::RejectedWithoutLegacyFallback,
1051        }
1052    }
1053}
1054
1055#[cfg(test)]
1056pub(crate) mod tests {
1057    use super::*;
1058
1059    #[test]
1060    fn protocol_version_serde_uses_only_exact_wire_versions() {
1061        for (version, wire_value) in [
1062            (ProtocolVersion::MODERN_2026, MODERN_PROTOCOL_VERSION),
1063            (ProtocolVersion::LEGACY_2024, LEGACY_PROTOCOL_VERSION),
1064        ] {
1065            assert_eq!(
1066                serde_json::to_value(version).expect("supported version serializes"),
1067                serde_json::json!(wire_value),
1068            );
1069            assert_eq!(
1070                serde_json::from_value::<ProtocolVersion>(serde_json::json!(wire_value))
1071                    .expect("exact supported wire version deserializes"),
1072                version,
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn protocol_version_serde_planted_negative_rejects_internal_variant_spelling() {
1079        let accepted_wire_value = serde_json::json!(MODERN_PROTOCOL_VERSION);
1080        let accepted = serde_json::from_value::<ProtocolVersion>(accepted_wire_value.clone())
1081            .expect("exact modern wire version is admitted");
1082
1083        // The wire spelling is the only changed dimension. Internal enum
1084        // labels are never protocol-version aliases.
1085        let rejected = serde_json::from_value::<ProtocolVersion>(serde_json::json!("Modern2026"))
1086            .expect_err("internal enum spelling must not be admitted on the wire");
1087
1088        assert!(
1089            rejected
1090                .to_string()
1091                .contains("unsupported MCP protocol version \"Modern2026\"")
1092        );
1093        assert_eq!(accepted, ProtocolVersion::MODERN_2026);
1094        assert_eq!(
1095            serde_json::to_value(accepted).expect("accepted version remains serializable"),
1096            accepted_wire_value,
1097        );
1098    }
1099
1100    #[test]
1101    #[test]
1102    fn auto_stdio_request_without_modern_metadata_selects_legacy() {
1103        let mut classifier = StdioEraClassifier::new(ProtocolPolicy::Auto);
1104        assert_eq!(
1105            classifier.classify_opening(StdioOpeningFrame::RequestWithoutModernMetadata),
1106            StdioEraDecision::Selected {
1107                era: ProtocolEra::Legacy2024,
1108                modern_version: None,
1109            }
1110        );
1111        assert_eq!(
1112            classifier.state(),
1113            &StdioEraState::Selected(ProtocolEra::Legacy2024)
1114        );
1115        assert_eq!(
1116            classifier.classify_opening(StdioOpeningFrame::RequestWithoutModernMetadata),
1117            StdioEraDecision::Selected {
1118                era: ProtocolEra::Legacy2024,
1119                modern_version: None,
1120            }
1121        );
1122    }
1123
1124    #[test]
1125    fn auto_stdio_planted_negative_treats_exact_legacy_claim_as_modern_contradiction() {
1126        let mut accepted = StdioEraClassifier::new(ProtocolPolicy::Auto);
1127        assert_eq!(
1128            accepted.classify_opening(StdioOpeningFrame::ModernRequest {
1129                protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
1130            }),
1131            StdioEraDecision::Selected {
1132                era: ProtocolEra::Modern2026,
1133                modern_version: Some(ModernVersionSupport::Supported),
1134            }
1135        );
1136        let accepted_state = accepted.state().clone();
1137
1138        let mut contradictory = StdioEraClassifier::new(ProtocolPolicy::Auto);
1139        assert_eq!(
1140            contradictory.classify_opening(StdioOpeningFrame::ModernRequest {
1141                protocol_version: LEGACY_PROTOCOL_VERSION.to_owned(),
1142            }),
1143            StdioEraDecision::Selected {
1144                era: ProtocolEra::Modern2026,
1145                modern_version: Some(ModernVersionSupport::Unsupported {
1146                    received: LEGACY_PROTOCOL_VERSION.to_owned(),
1147                }),
1148            }
1149        );
1150        assert_eq!(
1151            contradictory.classify_opening(StdioOpeningFrame::LegacyInitialize),
1152            StdioEraDecision::RejectedUnderSelectedEra {
1153                era: ProtocolEra::Modern2026,
1154                reason: StdioEraRejection::CrossEraTraffic,
1155            }
1156        );
1157        assert_eq!(accepted.state(), &accepted_state);
1158        assert_eq!(
1159            contradictory.state(),
1160            &StdioEraState::Selected(ProtocolEra::Modern2026)
1161        );
1162    }
1163
1164    #[test]
1165    fn auto_http_planted_negative_does_not_downgrade_a_recognized_modern_refusal() {
1166        let bundle = HttpEndpointBundle::new(
1167            ProtocolPolicy::Auto,
1168            Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
1169            Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
1170            Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
1171            "credential-partition-a".to_owned(),
1172            "security-partition-a".to_owned(),
1173            "http-sse-v2".to_owned(),
1174            1,
1175            1,
1176            1,
1177        )
1178        .expect("complete Auto bundle is valid");
1179
1180        let mut accepted = HttpEraCache::default();
1181        assert_eq!(
1182            accepted.classify_or_cached(
1183                &bundle,
1184                HttpModernProbe {
1185                    status: 200,
1186                    body: HttpProbeBody::RecognizedModernJsonRpc,
1187                },
1188            ),
1189            HttpEraDecision::Selected(ProtocolEra::Modern2026)
1190        );
1191        let accepted_era = accepted.selected_era(&bundle.key());
1192
1193        // The HTTP status is the sole changed dimension. A recognized modern
1194        // JSON-RPC response confirms the modern era even when it refuses the
1195        // discovery request, so it cannot authorize legacy fallback.
1196        let mut refusal = HttpEraCache::default();
1197        assert_eq!(
1198            refusal.classify_or_cached(
1199                &bundle,
1200                HttpModernProbe {
1201                    status: 404,
1202                    body: HttpProbeBody::RecognizedModernJsonRpc,
1203                },
1204            ),
1205            HttpEraDecision::Selected(ProtocolEra::Modern2026)
1206        );
1207        assert_eq!(accepted.selected_era(&bundle.key()), accepted_era);
1208        assert_eq!(
1209            refusal.selected_era(&bundle.key()),
1210            Some(ProtocolEra::Modern2026)
1211        );
1212    }
1213
1214    pub(crate) fn fnd_03_policy_receipts_positive() {
1215        assert_eq!(
1216            ProtocolVersion::parse(MODERN_PROTOCOL_VERSION),
1217            Ok(ProtocolVersion::MODERN_2026)
1218        );
1219        assert_eq!(
1220            ProtocolVersion::parse(LEGACY_PROTOCOL_VERSION),
1221            Ok(ProtocolVersion::LEGACY_2024)
1222        );
1223        assert_eq!(ProtocolVersion::MODERN_2026.era(), ProtocolEra::Modern2026);
1224        assert_eq!(ProtocolVersion::LEGACY_2024.era(), ProtocolEra::Legacy2024);
1225
1226        let policy = ProtocolPolicy::default();
1227        assert_eq!(policy, ProtocolPolicy::Auto);
1228        assert_eq!(
1229            policy.supported_versions(),
1230            [ProtocolVersion::MODERN_2026, ProtocolVersion::LEGACY_2024]
1231        );
1232        assert_eq!(
1233            policy.modern_discovery_versions(),
1234            [ProtocolVersion::MODERN_2026]
1235        );
1236        assert!(
1237            ProtocolPolicy::LegacyOnly
1238                .modern_discovery_versions()
1239                .is_empty()
1240        );
1241        assert_eq!(policy.preferred_versions(), policy.supported_versions());
1242        assert!(policy.permits(ProtocolVersion::MODERN_2026));
1243        assert!(policy.permits(ProtocolVersion::LEGACY_2024));
1244
1245        let modern_client = ProtocolPolicy::ModernOnly
1246            .validate_for_client(None)
1247            .expect("modern-only client policy requires no legacy receipt");
1248        let modern_server = ProtocolPolicy::ModernOnly
1249            .validate_for_server(None)
1250            .expect("modern-only server policy requires no legacy receipt");
1251        assert_eq!(modern_client.policy(), ProtocolPolicy::ModernOnly);
1252        assert_eq!(modern_client.role(), ProtocolRole::Client);
1253        assert_eq!(modern_server.policy(), ProtocolPolicy::ModernOnly);
1254        assert_eq!(modern_server.role(), ProtocolRole::Server);
1255    }
1256
1257    pub(crate) fn fnd_03_policy_receipts_planted_negative() {
1258        let accepted_policy = ProtocolPolicy::ModernOnly;
1259        let accepted_state = accepted_policy
1260            .validate_for_client(None)
1261            .expect("modern-only baseline must be accepted");
1262        let state_before_refusal = accepted_state;
1263
1264        // The selected policy is the sole planted dimension. Neither the role
1265        // nor the receipt input changes from the accepted baseline.
1266        let planted_policy = ProtocolPolicy::LegacyOnly;
1267        let refusal = planted_policy
1268            .validate_for_client(None)
1269            .expect_err("legacy-only policy without a receipt must be refused");
1270
1271        assert_eq!(
1272            refusal,
1273            ProtocolPolicyError::FeatureUnavailable {
1274                policy: ProtocolPolicy::LegacyOnly,
1275                role: ProtocolRole::Client,
1276            }
1277        );
1278        assert_eq!(accepted_state, state_before_refusal);
1279        assert_eq!(accepted_state.policy(), ProtocolPolicy::ModernOnly);
1280        assert_eq!(accepted_state.role(), ProtocolRole::Client);
1281        assert_eq!(
1282            ProtocolVersion::parse("2025-11-25"),
1283            Err(ProtocolVersionError::UnsupportedVersion {
1284                received: "2025-11-25".to_owned(),
1285            })
1286        );
1287    }
1288
1289    pub(crate) fn fnd_03_era_classification_positive() {
1290        let mut stdio = StdioEraClassifier::new(ProtocolPolicy::Auto);
1291        assert_eq!(stdio.state(), &StdioEraState::Unclassified);
1292        assert_eq!(
1293            stdio.classify_opening(StdioOpeningFrame::ModernRequest {
1294                protocol_version: "2025-11-25".to_owned(),
1295            }),
1296            StdioEraDecision::Selected {
1297                era: ProtocolEra::Modern2026,
1298                modern_version: Some(ModernVersionSupport::Unsupported {
1299                    received: "2025-11-25".to_owned(),
1300                }),
1301            }
1302        );
1303        assert_eq!(
1304            stdio.state(),
1305            &StdioEraState::Selected(ProtocolEra::Modern2026)
1306        );
1307        assert_eq!(
1308            stdio.classify_opening(StdioOpeningFrame::LegacyInitialize),
1309            StdioEraDecision::RejectedUnderSelectedEra {
1310                era: ProtocolEra::Modern2026,
1311                reason: StdioEraRejection::CrossEraTraffic,
1312            }
1313        );
1314
1315        let first_bundle = HttpEndpointBundle::new(
1316            ProtocolPolicy::Auto,
1317            Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
1318            Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
1319            Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
1320            "partition-a".to_owned(),
1321            "security-a".to_owned(),
1322            "http-sse-v2".to_owned(),
1323            1,
1324            1,
1325            1,
1326        )
1327        .unwrap();
1328        let second_bundle = HttpEndpointBundle::new(
1329            ProtocolPolicy::Auto,
1330            Some(CanonicalHttpUrl::parse("https://api.example.test/other-mcp").unwrap()),
1331            Some(CanonicalHttpUrl::parse("https://api.example.test/other-sse").unwrap()),
1332            Some(CanonicalHttpUrl::parse("https://api.example.test/other-messages").unwrap()),
1333            "partition-a".to_owned(),
1334            "security-a".to_owned(),
1335            "http-sse-v2".to_owned(),
1336            1,
1337            1,
1338            1,
1339        )
1340        .unwrap();
1341        assert_ne!(first_bundle.key(), second_bundle.key());
1342
1343        let mut cache = HttpEraCache::default();
1344        assert_eq!(
1345            cache.classify_or_cached(
1346                &first_bundle,
1347                HttpModernProbe {
1348                    status: 500,
1349                    body: HttpProbeBody::RecognizedModernJsonRpc,
1350                },
1351            ),
1352            HttpEraDecision::Selected(ProtocolEra::Modern2026)
1353        );
1354        assert_eq!(
1355            cache.classify_or_cached(
1356                &second_bundle,
1357                HttpModernProbe {
1358                    status: 404,
1359                    body: HttpProbeBody::Empty,
1360                },
1361            ),
1362            HttpEraDecision::LegacySseFallbackAuthorized
1363        );
1364        assert_eq!(
1365            cache.selected_era(&first_bundle.key()),
1366            Some(ProtocolEra::Modern2026)
1367        );
1368        assert_eq!(cache.selected_era(&second_bundle.key()), None);
1369    }
1370
1371    #[test]
1372    fn auto_http_refusal_authorizes_legacy_observation_without_selecting_or_caching_legacy() {
1373        let bundle = HttpEndpointBundle::new(
1374            ProtocolPolicy::Auto,
1375            Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
1376            Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
1377            Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
1378            "credential-partition-a".to_owned(),
1379            "security-partition-a".to_owned(),
1380            "http-sse-v2".to_owned(),
1381            1,
1382            1,
1383            1,
1384        )
1385        .expect("complete Auto bundle is valid");
1386
1387        let mut cache = HttpEraCache::default();
1388        assert_eq!(
1389            cache.classify_or_cached(
1390                &bundle,
1391                HttpModernProbe {
1392                    status: 404,
1393                    body: HttpProbeBody::Empty,
1394                },
1395            ),
1396            HttpEraDecision::LegacySseFallbackAuthorized
1397        );
1398        assert_eq!(cache.selected_era(&bundle.key()), None);
1399    }
1400
1401    pub(crate) fn fnd_03_era_classification_planted_negative() {
1402        let baseline_frame = StdioOpeningFrame::ModernRequest {
1403            protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
1404        };
1405        let mut accepted_classifier = StdioEraClassifier::new(ProtocolPolicy::Auto);
1406        assert_eq!(
1407            accepted_classifier.classify_opening(baseline_frame.clone()),
1408            StdioEraDecision::Selected {
1409                era: ProtocolEra::Modern2026,
1410                modern_version: Some(ModernVersionSupport::Supported),
1411            }
1412        );
1413        let accepted_state = accepted_classifier.state().clone();
1414
1415        // The initialize marker is the sole planted opening-frame dimension;
1416        // policy, request shape, and protocol version remain unchanged.
1417        let mut planted_classifier = StdioEraClassifier::new(ProtocolPolicy::Auto);
1418        let refusal = planted_classifier.classify_opening(
1419            StdioOpeningFrame::MixedInitializeAndModernMetadata {
1420                protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
1421            },
1422        );
1423        assert_eq!(
1424            refusal,
1425            StdioEraDecision::RejectedAndClosed {
1426                reason: StdioEraRejection::MixedEraMarkers,
1427            }
1428        );
1429        assert_eq!(
1430            planted_classifier.state(),
1431            &StdioEraState::TerminalWithoutEra
1432        );
1433        assert_eq!(accepted_classifier.state(), &accepted_state);
1434        assert_eq!(
1435            planted_classifier.classify_opening(baseline_frame),
1436            StdioEraDecision::AlreadyTerminal
1437        );
1438        assert_eq!(
1439            planted_classifier.state(),
1440            &StdioEraState::TerminalWithoutEra
1441        );
1442    }
1443}