1use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12
13use fastmcp_core::CanonicalHttpUrl;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
18
19pub const LEGACY_PROTOCOL_VERSION: &str = "2024-11-05";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub enum ProtocolEra {
25 Modern2026,
27 Legacy2024,
29}
30
31impl ProtocolEra {
32 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub struct ProtocolVersion(ProtocolEra);
50
51impl ProtocolVersion {
52 pub const MODERN_2026: Self = Self(ProtocolEra::Modern2026);
54
55 pub const LEGACY_2024: Self = Self(ProtocolEra::Legacy2024);
57
58 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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ProtocolVersionError {
121 UnsupportedVersion {
123 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub enum ProtocolPolicy {
147 #[default]
149 Auto,
150 ModernOnly,
152 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 #[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 #[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 #[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 #[must_use]
214 pub const fn preferred_versions(self) -> &'static [ProtocolVersion] {
215 self.supported_versions()
216 }
217
218 #[must_use]
220 pub const fn requires_legacy_adapter(self) -> bool {
221 !matches!(self, Self::ModernOnly)
222 }
223
224 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
273pub enum ProtocolRole {
274 Client,
276 Server,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub struct ProtocolPolicySelection {
283 policy: ProtocolPolicy,
284 role: ProtocolRole,
285}
286
287impl ProtocolPolicySelection {
288 #[must_use]
290 pub const fn policy(self) -> ProtocolPolicy {
291 self.policy
292 }
293
294 #[must_use]
296 pub const fn role(self) -> ProtocolRole {
297 self.role
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum ProtocolPolicyError {
304 FeatureUnavailable {
307 policy: ProtocolPolicy,
309 role: ProtocolRole,
311 },
312 ReceiptPolicyMismatch {
314 policy: ProtocolPolicy,
316 receipt_policy: ProtocolPolicy,
318 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#[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 #[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#[derive(Debug, PartialEq, Eq)]
390pub struct LegacyClientAdapterInstalledReceipt {
391 binding: LegacyReceiptBinding,
392}
393
394#[derive(Debug, PartialEq, Eq)]
396pub struct LegacyServerAdapterInstalledReceipt {
397 binding: LegacyReceiptBinding,
398}
399
400impl LegacyClientAdapterInstalledReceipt {
401 #[must_use]
403 pub const fn policy(&self) -> ProtocolPolicy {
404 self.binding.policy
405 }
406}
407
408impl LegacyServerAdapterInstalledReceipt {
409 #[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#[allow(private_bounds)]
440pub trait LegacyAdapterReceiptIssuer: sealed::ReceiptIssuerSealed {
441 #[doc(hidden)]
443 fn issue_client_receipt(binding: LegacyReceiptBinding) -> LegacyClientAdapterInstalledReceipt {
444 LegacyClientAdapterInstalledReceipt { binding }
445 }
446
447 #[doc(hidden)]
449 fn issue_server_receipt(binding: LegacyReceiptBinding) -> LegacyServerAdapterInstalledReceipt {
450 LegacyServerAdapterInstalledReceipt { binding }
451 }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
456pub enum StdioEraState {
457 Unclassified,
459 Selected(ProtocolEra),
461 TerminalWithoutEra,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum StdioOpeningFrame {
468 ModernRequest {
470 protocol_version: String,
472 },
473 LegacyInitialize,
475 MixedInitializeAndModernMetadata {
477 protocol_version: String,
479 },
480 RequestWithoutModernMetadata,
482 Notification,
484 Response,
486 Malformed,
488}
489
490#[derive(Debug, Clone, PartialEq, Eq)]
492pub enum ModernVersionSupport {
493 Supported,
495 Unsupported {
497 received: String,
499 },
500}
501
502#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum StdioEraDecision {
505 Selected {
507 era: ProtocolEra,
509 modern_version: Option<ModernVersionSupport>,
511 },
512 RejectedUnderSelectedEra {
514 era: ProtocolEra,
516 reason: StdioEraRejection,
518 },
519 RejectedAndClosed {
521 reason: StdioEraRejection,
523 },
524 AlreadyTerminal,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530pub enum StdioEraRejection {
531 MixedEraMarkers,
533 MissingModernMetadata,
535 NotificationCannotClassify,
537 ResponseCannotClassify,
539 MalformedOpeningFrame,
541 LegacyInitializeRequired,
543 CrossEraTraffic,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct StdioEraClassifier {
550 policy: ProtocolPolicy,
551 state: StdioEraState,
552}
553
554impl StdioEraClassifier {
555 #[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 #[must_use]
568 pub const fn policy(&self) -> ProtocolPolicy {
569 self.policy
570 }
571
572 #[must_use]
574 pub const fn state(&self) -> &StdioEraState {
575 &self.state
576 }
577
578 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
725pub enum HttpRouteKind {
726 ModernMcpPost,
728 LegacySseGet,
730 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#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct HttpEndpointBundle {
760 key: HttpEndpointBundleKey,
761}
762
763#[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#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum HttpEndpointBundleError {
785 MissingModernPostTarget {
787 policy: ProtocolPolicy,
789 },
790 MissingLegacySseTarget {
792 policy: ProtocolPolicy,
794 },
795 MissingLegacyMessagePostTarget {
797 policy: ProtocolPolicy,
799 },
800 FragmentNotAllowed {
802 route: HttpRouteKind,
804 },
805 RouteCollision {
807 first: HttpRouteKind,
809 second: HttpRouteKind,
811 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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
957pub enum HttpProbeBody {
958 RecognizedModernJsonRpc,
960 Empty,
962 Unrecognized,
964 TransportFailure,
966}
967
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub struct HttpModernProbe {
971 pub status: u16,
973 pub body: HttpProbeBody,
975}
976
977#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979pub enum HttpEraDecision {
980 Selected(ProtocolEra),
982 LegacySseFallbackAuthorized,
987 RejectedWithoutLegacyFallback,
989}
990
991#[derive(Debug, Default)]
993pub struct HttpEraCache {
994 selected_eras: HashMap<HttpEndpointBundleKey, ProtocolEra>,
995}
996
997impl HttpEraCache {
998 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 pub fn invalidate(&mut self, key: &HttpEndpointBundleKey) -> Option<ProtocolEra> {
1018 self.selected_eras.remove(key)
1019 }
1020
1021 #[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 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 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 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 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}