use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use fastmcp_core::CanonicalHttpUrl;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
pub const LEGACY_PROTOCOL_VERSION: &str = "2024-11-05";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ProtocolEra {
Modern2026,
Legacy2024,
}
impl ProtocolEra {
#[must_use]
pub const fn version(self) -> ProtocolVersion {
match self {
Self::Modern2026 => ProtocolVersion::MODERN_2026,
Self::Legacy2024 => ProtocolVersion::LEGACY_2024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ProtocolVersion(ProtocolEra);
impl ProtocolVersion {
pub const MODERN_2026: Self = Self(ProtocolEra::Modern2026);
pub const LEGACY_2024: Self = Self(ProtocolEra::Legacy2024);
pub fn parse(value: &str) -> Result<Self, ProtocolVersionError> {
match value {
MODERN_PROTOCOL_VERSION => Ok(Self::MODERN_2026),
LEGACY_PROTOCOL_VERSION => Ok(Self::LEGACY_2024),
_ => Err(ProtocolVersionError::UnsupportedVersion {
received: value.to_owned(),
}),
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self.0 {
ProtocolEra::Modern2026 => MODERN_PROTOCOL_VERSION,
ProtocolEra::Legacy2024 => LEGACY_PROTOCOL_VERSION,
}
}
#[must_use]
pub const fn era(self) -> ProtocolEra {
self.0
}
}
impl fmt::Display for ProtocolVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ProtocolVersion {
type Err = ProtocolVersionError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl Serialize for ProtocolVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ProtocolVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProtocolVersionError {
UnsupportedVersion {
received: String,
},
}
impl fmt::Display for ProtocolVersionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedVersion { received } => {
write!(formatter, "unsupported MCP protocol version {received:?}")
}
}
}
}
impl std::error::Error for ProtocolVersionError {}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProtocolPolicy {
#[default]
Auto,
ModernOnly,
LegacyOnly,
}
const MODERN_ONLY_VERSIONS: [ProtocolVersion; 1] = [ProtocolVersion::MODERN_2026];
const LEGACY_ONLY_VERSIONS: [ProtocolVersion; 1] = [ProtocolVersion::LEGACY_2024];
const AUTO_SUPPORTED_VERSIONS: [ProtocolVersion; 2] =
[ProtocolVersion::MODERN_2026, ProtocolVersion::LEGACY_2024];
const NO_MODERN_DISCOVERY_VERSIONS: [ProtocolVersion; 0] = [];
impl ProtocolPolicy {
const fn display_name(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::ModernOnly => "modern-only",
Self::LegacyOnly => "legacy-only",
}
}
#[must_use]
pub const fn permits(self, version: ProtocolVersion) -> bool {
match self {
Self::Auto => true,
Self::ModernOnly => matches!(version.era(), ProtocolEra::Modern2026),
Self::LegacyOnly => matches!(version.era(), ProtocolEra::Legacy2024),
}
}
#[must_use]
pub const fn supported_versions(self) -> &'static [ProtocolVersion] {
match self {
Self::Auto => &AUTO_SUPPORTED_VERSIONS,
Self::ModernOnly => &MODERN_ONLY_VERSIONS,
Self::LegacyOnly => &LEGACY_ONLY_VERSIONS,
}
}
#[must_use]
pub const fn modern_discovery_versions(self) -> &'static [ProtocolVersion] {
match self {
Self::Auto | Self::ModernOnly => &MODERN_ONLY_VERSIONS,
Self::LegacyOnly => &NO_MODERN_DISCOVERY_VERSIONS,
}
}
#[must_use]
pub const fn preferred_versions(self) -> &'static [ProtocolVersion] {
self.supported_versions()
}
#[must_use]
pub const fn requires_legacy_adapter(self) -> bool {
!matches!(self, Self::ModernOnly)
}
pub fn validate_for_client(
self,
legacy_receipt: Option<&LegacyClientAdapterInstalledReceipt>,
) -> Result<ProtocolPolicySelection, ProtocolPolicyError> {
self.validate(
ProtocolRole::Client,
legacy_receipt.map(LegacyReceipt::Client),
)
}
pub fn validate_for_server(
self,
legacy_receipt: Option<&LegacyServerAdapterInstalledReceipt>,
) -> Result<ProtocolPolicySelection, ProtocolPolicyError> {
self.validate(
ProtocolRole::Server,
legacy_receipt.map(LegacyReceipt::Server),
)
}
fn validate(
self,
role: ProtocolRole,
legacy_receipt: Option<LegacyReceipt<'_>>,
) -> Result<ProtocolPolicySelection, ProtocolPolicyError> {
if !self.requires_legacy_adapter() {
return Ok(ProtocolPolicySelection { policy: self, role });
}
let Some(receipt) = legacy_receipt else {
return Err(ProtocolPolicyError::FeatureUnavailable { policy: self, role });
};
if receipt.policy() != self {
return Err(ProtocolPolicyError::ReceiptPolicyMismatch {
policy: self,
receipt_policy: receipt.policy(),
role,
});
}
Ok(ProtocolPolicySelection { policy: self, role })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProtocolRole {
Client,
Server,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProtocolPolicySelection {
policy: ProtocolPolicy,
role: ProtocolRole,
}
impl ProtocolPolicySelection {
#[must_use]
pub const fn policy(self) -> ProtocolPolicy {
self.policy
}
#[must_use]
pub const fn role(self) -> ProtocolRole {
self.role
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolPolicyError {
FeatureUnavailable {
policy: ProtocolPolicy,
role: ProtocolRole,
},
ReceiptPolicyMismatch {
policy: ProtocolPolicy,
receipt_policy: ProtocolPolicy,
role: ProtocolRole,
},
}
impl fmt::Display for ProtocolPolicyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FeatureUnavailable { policy, role } => {
write!(
formatter,
"{policy:?} is unavailable for {role:?} without a legacy adapter receipt"
)
}
Self::ReceiptPolicyMismatch {
policy,
receipt_policy,
role,
} => write!(
formatter,
"{role:?} legacy receipt is bound to {receipt_policy:?}, not {policy:?}"
),
}
}
}
impl std::error::Error for ProtocolPolicyError {}
#[derive(Debug, PartialEq, Eq)]
pub struct LegacyReceiptBinding {
policy: ProtocolPolicy,
transport_binding: String,
endpoint_or_process_configuration: String,
security_partition: String,
adapter_generation: u64,
store_generation: u64,
configuration_generation: u64,
limits_profile_identity: String,
}
impl LegacyReceiptBinding {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
policy: ProtocolPolicy,
transport_binding: String,
endpoint_or_process_configuration: String,
security_partition: String,
adapter_generation: u64,
store_generation: u64,
configuration_generation: u64,
limits_profile_identity: String,
) -> Self {
Self {
policy,
transport_binding,
endpoint_or_process_configuration,
security_partition,
adapter_generation,
store_generation,
configuration_generation,
limits_profile_identity,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct LegacyClientAdapterInstalledReceipt {
binding: LegacyReceiptBinding,
}
#[derive(Debug, PartialEq, Eq)]
pub struct LegacyServerAdapterInstalledReceipt {
binding: LegacyReceiptBinding,
}
impl LegacyClientAdapterInstalledReceipt {
#[must_use]
pub const fn policy(&self) -> ProtocolPolicy {
self.binding.policy
}
}
impl LegacyServerAdapterInstalledReceipt {
#[must_use]
pub const fn policy(&self) -> ProtocolPolicy {
self.binding.policy
}
}
enum LegacyReceipt<'a> {
Client(&'a LegacyClientAdapterInstalledReceipt),
Server(&'a LegacyServerAdapterInstalledReceipt),
}
impl LegacyReceipt<'_> {
const fn policy(&self) -> ProtocolPolicy {
match self {
Self::Client(receipt) => receipt.policy(),
Self::Server(receipt) => receipt.policy(),
}
}
}
mod sealed {
pub trait ReceiptIssuerSealed {}
}
#[allow(private_bounds)]
pub trait LegacyAdapterReceiptIssuer: sealed::ReceiptIssuerSealed {
#[doc(hidden)]
fn issue_client_receipt(binding: LegacyReceiptBinding) -> LegacyClientAdapterInstalledReceipt {
LegacyClientAdapterInstalledReceipt { binding }
}
#[doc(hidden)]
fn issue_server_receipt(binding: LegacyReceiptBinding) -> LegacyServerAdapterInstalledReceipt {
LegacyServerAdapterInstalledReceipt { binding }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StdioEraState {
Unclassified,
Selected(ProtocolEra),
TerminalWithoutEra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StdioOpeningFrame {
ModernRequest {
protocol_version: String,
},
LegacyInitialize,
MixedInitializeAndModernMetadata {
protocol_version: String,
},
RequestWithoutModernMetadata,
Notification,
Response,
Malformed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModernVersionSupport {
Supported,
Unsupported {
received: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StdioEraDecision {
Selected {
era: ProtocolEra,
modern_version: Option<ModernVersionSupport>,
},
RejectedUnderSelectedEra {
era: ProtocolEra,
reason: StdioEraRejection,
},
RejectedAndClosed {
reason: StdioEraRejection,
},
AlreadyTerminal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StdioEraRejection {
MixedEraMarkers,
MissingModernMetadata,
NotificationCannotClassify,
ResponseCannotClassify,
MalformedOpeningFrame,
LegacyInitializeRequired,
CrossEraTraffic,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StdioEraClassifier {
policy: ProtocolPolicy,
state: StdioEraState,
}
impl StdioEraClassifier {
#[must_use]
pub const fn new(policy: ProtocolPolicy) -> Self {
let state = match policy {
ProtocolPolicy::Auto => StdioEraState::Unclassified,
ProtocolPolicy::ModernOnly => StdioEraState::Selected(ProtocolEra::Modern2026),
ProtocolPolicy::LegacyOnly => StdioEraState::Selected(ProtocolEra::Legacy2024),
};
Self { policy, state }
}
#[must_use]
pub const fn policy(&self) -> ProtocolPolicy {
self.policy
}
#[must_use]
pub const fn state(&self) -> &StdioEraState {
&self.state
}
pub fn classify_opening(&mut self, frame: StdioOpeningFrame) -> StdioEraDecision {
match self.state {
StdioEraState::TerminalWithoutEra => StdioEraDecision::AlreadyTerminal,
StdioEraState::Unclassified => self.classify_auto(frame),
StdioEraState::Selected(era) => self.classify_fixed(era, frame),
}
}
fn classify_auto(&mut self, frame: StdioOpeningFrame) -> StdioEraDecision {
match frame {
StdioOpeningFrame::ModernRequest { protocol_version } => {
self.select_modern(protocol_version)
}
StdioOpeningFrame::LegacyInitialize => {
self.state = StdioEraState::Selected(ProtocolEra::Legacy2024);
StdioEraDecision::Selected {
era: ProtocolEra::Legacy2024,
modern_version: None,
}
}
StdioOpeningFrame::MixedInitializeAndModernMetadata { .. } => {
self.reject_and_close(StdioEraRejection::MixedEraMarkers)
}
StdioOpeningFrame::RequestWithoutModernMetadata => {
self.state = StdioEraState::Selected(ProtocolEra::Legacy2024);
StdioEraDecision::Selected {
era: ProtocolEra::Legacy2024,
modern_version: None,
}
}
StdioOpeningFrame::Notification => {
self.reject_and_close(StdioEraRejection::NotificationCannotClassify)
}
StdioOpeningFrame::Response => {
self.reject_and_close(StdioEraRejection::ResponseCannotClassify)
}
StdioOpeningFrame::Malformed => {
self.reject_and_close(StdioEraRejection::MalformedOpeningFrame)
}
}
}
fn classify_fixed(&mut self, era: ProtocolEra, frame: StdioOpeningFrame) -> StdioEraDecision {
match (era, frame) {
(ProtocolEra::Modern2026, StdioOpeningFrame::ModernRequest { protocol_version }) => {
Self::modern_decision(protocol_version)
}
(ProtocolEra::Modern2026, StdioOpeningFrame::LegacyInitialize) => {
StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::CrossEraTraffic,
}
}
(
ProtocolEra::Modern2026,
StdioOpeningFrame::MixedInitializeAndModernMetadata { .. },
) => StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::MixedEraMarkers,
},
(ProtocolEra::Modern2026, StdioOpeningFrame::RequestWithoutModernMetadata) => {
StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::MissingModernMetadata,
}
}
(ProtocolEra::Modern2026, StdioOpeningFrame::Notification) => {
StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::NotificationCannotClassify,
}
}
(ProtocolEra::Modern2026, StdioOpeningFrame::Response) => {
StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::ResponseCannotClassify,
}
}
(ProtocolEra::Modern2026, StdioOpeningFrame::Malformed) => {
StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::MalformedOpeningFrame,
}
}
(ProtocolEra::Legacy2024, StdioOpeningFrame::LegacyInitialize) => {
StdioEraDecision::Selected {
era,
modern_version: None,
}
}
(
ProtocolEra::Legacy2024,
StdioOpeningFrame::RequestWithoutModernMetadata | StdioOpeningFrame::Notification,
) => StdioEraDecision::Selected {
era,
modern_version: None,
},
(
ProtocolEra::Legacy2024,
StdioOpeningFrame::ModernRequest { .. }
| StdioOpeningFrame::MixedInitializeAndModernMetadata { .. },
) => StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::CrossEraTraffic,
},
(ProtocolEra::Legacy2024, _) => StdioEraDecision::RejectedUnderSelectedEra {
era,
reason: StdioEraRejection::LegacyInitializeRequired,
},
}
}
fn select_modern(&mut self, protocol_version: String) -> StdioEraDecision {
self.state = StdioEraState::Selected(ProtocolEra::Modern2026);
Self::modern_decision(protocol_version)
}
fn modern_decision(protocol_version: String) -> StdioEraDecision {
let modern_version = match ProtocolVersion::parse(&protocol_version) {
Ok(ProtocolVersion::MODERN_2026) => ModernVersionSupport::Supported,
Ok(ProtocolVersion::LEGACY_2024)
| Err(ProtocolVersionError::UnsupportedVersion { .. }) => {
ModernVersionSupport::Unsupported {
received: protocol_version,
}
}
};
StdioEraDecision::Selected {
era: ProtocolEra::Modern2026,
modern_version: Some(modern_version),
}
}
fn reject_and_close(&mut self, reason: StdioEraRejection) -> StdioEraDecision {
self.state = StdioEraState::TerminalWithoutEra;
StdioEraDecision::RejectedAndClosed { reason }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HttpRouteKind {
ModernMcpPost,
LegacySseGet,
LegacyMessagePost,
}
impl HttpRouteKind {
const fn method(self) -> &'static str {
match self {
Self::ModernMcpPost | Self::LegacyMessagePost => "POST",
Self::LegacySseGet => "GET",
}
}
const fn display_name(self) -> &'static str {
match self {
Self::ModernMcpPost => "modern MCP POST",
Self::LegacySseGet => "legacy SSE GET",
Self::LegacyMessagePost => "legacy message POST",
}
}
}
impl fmt::Display for HttpRouteKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.display_name())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpEndpointBundle {
key: HttpEndpointBundleKey,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HttpEndpointBundleKey {
modern_post_target: Option<String>,
legacy_sse_target: Option<String>,
legacy_message_post_target: Option<String>,
credential_partition: String,
security_partition: String,
transport_profile: String,
policy: ProtocolPolicy,
policy_generation: u64,
configuration_generation: u64,
legacy_receipt_generation: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HttpEndpointBundleError {
MissingModernPostTarget {
policy: ProtocolPolicy,
},
MissingLegacySseTarget {
policy: ProtocolPolicy,
},
MissingLegacyMessagePostTarget {
policy: ProtocolPolicy,
},
FragmentNotAllowed {
route: HttpRouteKind,
},
RouteCollision {
first: HttpRouteKind,
second: HttpRouteKind,
target: String,
},
}
impl fmt::Display for HttpEndpointBundleError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingModernPostTarget { policy } => write!(
formatter,
"protocol policy {} requires a configured modern MCP POST target",
policy.display_name()
),
Self::MissingLegacySseTarget { policy } => write!(
formatter,
"protocol policy {} requires a configured legacy SSE GET target",
policy.display_name()
),
Self::MissingLegacyMessagePostTarget { policy } => write!(
formatter,
"protocol policy {} requires a configured legacy message POST target",
policy.display_name()
),
Self::FragmentNotAllowed { route } => write!(
formatter,
"configured {route} target must not contain a fragment"
),
Self::RouteCollision {
first,
second,
target,
} => write!(
formatter,
"configured {first} and {second} routes collide at {target}"
),
}
}
}
impl std::error::Error for HttpEndpointBundleError {}
impl HttpEndpointBundle {
#[allow(clippy::too_many_arguments)]
pub fn new(
policy: ProtocolPolicy,
modern_post: Option<CanonicalHttpUrl>,
legacy_sse: Option<CanonicalHttpUrl>,
legacy_message_post: Option<CanonicalHttpUrl>,
credential_partition: String,
security_partition: String,
transport_profile: String,
policy_generation: u64,
configuration_generation: u64,
legacy_receipt_generation: u64,
) -> Result<Self, HttpEndpointBundleError> {
let requires_modern = !matches!(policy, ProtocolPolicy::LegacyOnly);
let requires_legacy = !matches!(policy, ProtocolPolicy::ModernOnly);
if requires_modern && modern_post.is_none() {
return Err(HttpEndpointBundleError::MissingModernPostTarget { policy });
}
if requires_legacy && legacy_sse.is_none() {
return Err(HttpEndpointBundleError::MissingLegacySseTarget { policy });
}
if requires_legacy && legacy_message_post.is_none() {
return Err(HttpEndpointBundleError::MissingLegacyMessagePostTarget { policy });
}
Self::reject_fragment(modern_post.as_ref(), HttpRouteKind::ModernMcpPost)?;
Self::reject_fragment(legacy_sse.as_ref(), HttpRouteKind::LegacySseGet)?;
Self::reject_fragment(
legacy_message_post.as_ref(),
HttpRouteKind::LegacyMessagePost,
)?;
let key = HttpEndpointBundleKey {
modern_post_target: modern_post.map(|target| target.as_str().to_owned()),
legacy_sse_target: legacy_sse.map(|target| target.as_str().to_owned()),
legacy_message_post_target: legacy_message_post
.map(|target| target.as_str().to_owned()),
credential_partition,
security_partition,
transport_profile,
policy,
policy_generation,
configuration_generation,
legacy_receipt_generation,
};
Self::reject_route_collisions(&key)?;
Ok(Self { key })
}
#[must_use]
pub fn key(&self) -> HttpEndpointBundleKey {
self.key.clone()
}
fn reject_fragment(
target: Option<&CanonicalHttpUrl>,
route: HttpRouteKind,
) -> Result<(), HttpEndpointBundleError> {
if target.is_some_and(|target| target.fragment().is_some()) {
return Err(HttpEndpointBundleError::FragmentNotAllowed { route });
}
Ok(())
}
fn reject_route_collisions(key: &HttpEndpointBundleKey) -> Result<(), HttpEndpointBundleError> {
let routes = [
(
HttpRouteKind::ModernMcpPost,
key.modern_post_target.as_deref(),
),
(
HttpRouteKind::LegacySseGet,
key.legacy_sse_target.as_deref(),
),
(
HttpRouteKind::LegacyMessagePost,
key.legacy_message_post_target.as_deref(),
),
];
for (index, (first_kind, first_target)) in routes.iter().enumerate() {
let Some(first_target) = first_target else {
continue;
};
for (second_kind, second_target) in routes.iter().skip(index + 1) {
if first_kind.method() == second_kind.method()
&& second_target.is_some_and(|second_target| second_target == *first_target)
{
return Err(HttpEndpointBundleError::RouteCollision {
first: *first_kind,
second: *second_kind,
target: (*first_target).to_owned(),
});
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpProbeBody {
RecognizedModernJsonRpc,
Empty,
Unrecognized,
TransportFailure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HttpModernProbe {
pub status: u16,
pub body: HttpProbeBody,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpEraDecision {
Selected(ProtocolEra),
LegacySseFallbackAuthorized,
RejectedWithoutLegacyFallback,
}
#[derive(Debug, Default)]
pub struct HttpEraCache {
selected_eras: HashMap<HttpEndpointBundleKey, ProtocolEra>,
}
impl HttpEraCache {
pub fn classify_or_cached(
&mut self,
bundle: &HttpEndpointBundle,
probe: HttpModernProbe,
) -> HttpEraDecision {
let key = bundle.key();
if let Some(era) = self.selected_eras.get(&key) {
return HttpEraDecision::Selected(*era);
}
let decision = Self::classify_probe(bundle.key.policy, probe);
if let HttpEraDecision::Selected(era) = decision {
self.selected_eras.insert(key, era);
}
decision
}
pub fn invalidate(&mut self, key: &HttpEndpointBundleKey) -> Option<ProtocolEra> {
self.selected_eras.remove(key)
}
#[must_use]
pub fn selected_era(&self, key: &HttpEndpointBundleKey) -> Option<ProtocolEra> {
self.selected_eras.get(key).copied()
}
fn classify_probe(policy: ProtocolPolicy, probe: HttpModernProbe) -> HttpEraDecision {
match policy {
ProtocolPolicy::ModernOnly
if matches!(probe.body, HttpProbeBody::RecognizedModernJsonRpc) =>
{
HttpEraDecision::Selected(ProtocolEra::Modern2026)
}
ProtocolPolicy::ModernOnly => HttpEraDecision::RejectedWithoutLegacyFallback,
ProtocolPolicy::LegacyOnly => HttpEraDecision::Selected(ProtocolEra::Legacy2024),
ProtocolPolicy::Auto
if matches!(probe.body, HttpProbeBody::RecognizedModernJsonRpc) =>
{
HttpEraDecision::Selected(ProtocolEra::Modern2026)
}
ProtocolPolicy::Auto
if matches!(probe.status, 400 | 404 | 405)
&& matches!(
probe.body,
HttpProbeBody::Empty | HttpProbeBody::Unrecognized
) =>
{
HttpEraDecision::LegacySseFallbackAuthorized
}
ProtocolPolicy::Auto => HttpEraDecision::RejectedWithoutLegacyFallback,
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn protocol_version_serde_uses_only_exact_wire_versions() {
for (version, wire_value) in [
(ProtocolVersion::MODERN_2026, MODERN_PROTOCOL_VERSION),
(ProtocolVersion::LEGACY_2024, LEGACY_PROTOCOL_VERSION),
] {
assert_eq!(
serde_json::to_value(version).expect("supported version serializes"),
serde_json::json!(wire_value),
);
assert_eq!(
serde_json::from_value::<ProtocolVersion>(serde_json::json!(wire_value))
.expect("exact supported wire version deserializes"),
version,
);
}
}
#[test]
fn protocol_version_serde_planted_negative_rejects_internal_variant_spelling() {
let accepted_wire_value = serde_json::json!(MODERN_PROTOCOL_VERSION);
let accepted = serde_json::from_value::<ProtocolVersion>(accepted_wire_value.clone())
.expect("exact modern wire version is admitted");
let rejected = serde_json::from_value::<ProtocolVersion>(serde_json::json!("Modern2026"))
.expect_err("internal enum spelling must not be admitted on the wire");
assert!(
rejected
.to_string()
.contains("unsupported MCP protocol version \"Modern2026\"")
);
assert_eq!(accepted, ProtocolVersion::MODERN_2026);
assert_eq!(
serde_json::to_value(accepted).expect("accepted version remains serializable"),
accepted_wire_value,
);
}
#[test]
#[test]
fn auto_stdio_request_without_modern_metadata_selects_legacy() {
let mut classifier = StdioEraClassifier::new(ProtocolPolicy::Auto);
assert_eq!(
classifier.classify_opening(StdioOpeningFrame::RequestWithoutModernMetadata),
StdioEraDecision::Selected {
era: ProtocolEra::Legacy2024,
modern_version: None,
}
);
assert_eq!(
classifier.state(),
&StdioEraState::Selected(ProtocolEra::Legacy2024)
);
assert_eq!(
classifier.classify_opening(StdioOpeningFrame::RequestWithoutModernMetadata),
StdioEraDecision::Selected {
era: ProtocolEra::Legacy2024,
modern_version: None,
}
);
}
#[test]
fn auto_stdio_planted_negative_treats_exact_legacy_claim_as_modern_contradiction() {
let mut accepted = StdioEraClassifier::new(ProtocolPolicy::Auto);
assert_eq!(
accepted.classify_opening(StdioOpeningFrame::ModernRequest {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
}),
StdioEraDecision::Selected {
era: ProtocolEra::Modern2026,
modern_version: Some(ModernVersionSupport::Supported),
}
);
let accepted_state = accepted.state().clone();
let mut contradictory = StdioEraClassifier::new(ProtocolPolicy::Auto);
assert_eq!(
contradictory.classify_opening(StdioOpeningFrame::ModernRequest {
protocol_version: LEGACY_PROTOCOL_VERSION.to_owned(),
}),
StdioEraDecision::Selected {
era: ProtocolEra::Modern2026,
modern_version: Some(ModernVersionSupport::Unsupported {
received: LEGACY_PROTOCOL_VERSION.to_owned(),
}),
}
);
assert_eq!(
contradictory.classify_opening(StdioOpeningFrame::LegacyInitialize),
StdioEraDecision::RejectedUnderSelectedEra {
era: ProtocolEra::Modern2026,
reason: StdioEraRejection::CrossEraTraffic,
}
);
assert_eq!(accepted.state(), &accepted_state);
assert_eq!(
contradictory.state(),
&StdioEraState::Selected(ProtocolEra::Modern2026)
);
}
#[test]
fn auto_http_planted_negative_does_not_downgrade_a_recognized_modern_refusal() {
let bundle = HttpEndpointBundle::new(
ProtocolPolicy::Auto,
Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
"credential-partition-a".to_owned(),
"security-partition-a".to_owned(),
"http-sse-v2".to_owned(),
1,
1,
1,
)
.expect("complete Auto bundle is valid");
let mut accepted = HttpEraCache::default();
assert_eq!(
accepted.classify_or_cached(
&bundle,
HttpModernProbe {
status: 200,
body: HttpProbeBody::RecognizedModernJsonRpc,
},
),
HttpEraDecision::Selected(ProtocolEra::Modern2026)
);
let accepted_era = accepted.selected_era(&bundle.key());
let mut refusal = HttpEraCache::default();
assert_eq!(
refusal.classify_or_cached(
&bundle,
HttpModernProbe {
status: 404,
body: HttpProbeBody::RecognizedModernJsonRpc,
},
),
HttpEraDecision::Selected(ProtocolEra::Modern2026)
);
assert_eq!(accepted.selected_era(&bundle.key()), accepted_era);
assert_eq!(
refusal.selected_era(&bundle.key()),
Some(ProtocolEra::Modern2026)
);
}
pub(crate) fn fnd_03_policy_receipts_positive() {
assert_eq!(
ProtocolVersion::parse(MODERN_PROTOCOL_VERSION),
Ok(ProtocolVersion::MODERN_2026)
);
assert_eq!(
ProtocolVersion::parse(LEGACY_PROTOCOL_VERSION),
Ok(ProtocolVersion::LEGACY_2024)
);
assert_eq!(ProtocolVersion::MODERN_2026.era(), ProtocolEra::Modern2026);
assert_eq!(ProtocolVersion::LEGACY_2024.era(), ProtocolEra::Legacy2024);
let policy = ProtocolPolicy::default();
assert_eq!(policy, ProtocolPolicy::Auto);
assert_eq!(
policy.supported_versions(),
[ProtocolVersion::MODERN_2026, ProtocolVersion::LEGACY_2024]
);
assert_eq!(
policy.modern_discovery_versions(),
[ProtocolVersion::MODERN_2026]
);
assert!(
ProtocolPolicy::LegacyOnly
.modern_discovery_versions()
.is_empty()
);
assert_eq!(policy.preferred_versions(), policy.supported_versions());
assert!(policy.permits(ProtocolVersion::MODERN_2026));
assert!(policy.permits(ProtocolVersion::LEGACY_2024));
let modern_client = ProtocolPolicy::ModernOnly
.validate_for_client(None)
.expect("modern-only client policy requires no legacy receipt");
let modern_server = ProtocolPolicy::ModernOnly
.validate_for_server(None)
.expect("modern-only server policy requires no legacy receipt");
assert_eq!(modern_client.policy(), ProtocolPolicy::ModernOnly);
assert_eq!(modern_client.role(), ProtocolRole::Client);
assert_eq!(modern_server.policy(), ProtocolPolicy::ModernOnly);
assert_eq!(modern_server.role(), ProtocolRole::Server);
}
pub(crate) fn fnd_03_policy_receipts_planted_negative() {
let accepted_policy = ProtocolPolicy::ModernOnly;
let accepted_state = accepted_policy
.validate_for_client(None)
.expect("modern-only baseline must be accepted");
let state_before_refusal = accepted_state;
let planted_policy = ProtocolPolicy::LegacyOnly;
let refusal = planted_policy
.validate_for_client(None)
.expect_err("legacy-only policy without a receipt must be refused");
assert_eq!(
refusal,
ProtocolPolicyError::FeatureUnavailable {
policy: ProtocolPolicy::LegacyOnly,
role: ProtocolRole::Client,
}
);
assert_eq!(accepted_state, state_before_refusal);
assert_eq!(accepted_state.policy(), ProtocolPolicy::ModernOnly);
assert_eq!(accepted_state.role(), ProtocolRole::Client);
assert_eq!(
ProtocolVersion::parse("2025-11-25"),
Err(ProtocolVersionError::UnsupportedVersion {
received: "2025-11-25".to_owned(),
})
);
}
pub(crate) fn fnd_03_era_classification_positive() {
let mut stdio = StdioEraClassifier::new(ProtocolPolicy::Auto);
assert_eq!(stdio.state(), &StdioEraState::Unclassified);
assert_eq!(
stdio.classify_opening(StdioOpeningFrame::ModernRequest {
protocol_version: "2025-11-25".to_owned(),
}),
StdioEraDecision::Selected {
era: ProtocolEra::Modern2026,
modern_version: Some(ModernVersionSupport::Unsupported {
received: "2025-11-25".to_owned(),
}),
}
);
assert_eq!(
stdio.state(),
&StdioEraState::Selected(ProtocolEra::Modern2026)
);
assert_eq!(
stdio.classify_opening(StdioOpeningFrame::LegacyInitialize),
StdioEraDecision::RejectedUnderSelectedEra {
era: ProtocolEra::Modern2026,
reason: StdioEraRejection::CrossEraTraffic,
}
);
let first_bundle = HttpEndpointBundle::new(
ProtocolPolicy::Auto,
Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
"partition-a".to_owned(),
"security-a".to_owned(),
"http-sse-v2".to_owned(),
1,
1,
1,
)
.unwrap();
let second_bundle = HttpEndpointBundle::new(
ProtocolPolicy::Auto,
Some(CanonicalHttpUrl::parse("https://api.example.test/other-mcp").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/other-sse").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/other-messages").unwrap()),
"partition-a".to_owned(),
"security-a".to_owned(),
"http-sse-v2".to_owned(),
1,
1,
1,
)
.unwrap();
assert_ne!(first_bundle.key(), second_bundle.key());
let mut cache = HttpEraCache::default();
assert_eq!(
cache.classify_or_cached(
&first_bundle,
HttpModernProbe {
status: 500,
body: HttpProbeBody::RecognizedModernJsonRpc,
},
),
HttpEraDecision::Selected(ProtocolEra::Modern2026)
);
assert_eq!(
cache.classify_or_cached(
&second_bundle,
HttpModernProbe {
status: 404,
body: HttpProbeBody::Empty,
},
),
HttpEraDecision::LegacySseFallbackAuthorized
);
assert_eq!(
cache.selected_era(&first_bundle.key()),
Some(ProtocolEra::Modern2026)
);
assert_eq!(cache.selected_era(&second_bundle.key()), None);
}
#[test]
fn auto_http_refusal_authorizes_legacy_observation_without_selecting_or_caching_legacy() {
let bundle = HttpEndpointBundle::new(
ProtocolPolicy::Auto,
Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
"credential-partition-a".to_owned(),
"security-partition-a".to_owned(),
"http-sse-v2".to_owned(),
1,
1,
1,
)
.expect("complete Auto bundle is valid");
let mut cache = HttpEraCache::default();
assert_eq!(
cache.classify_or_cached(
&bundle,
HttpModernProbe {
status: 404,
body: HttpProbeBody::Empty,
},
),
HttpEraDecision::LegacySseFallbackAuthorized
);
assert_eq!(cache.selected_era(&bundle.key()), None);
}
pub(crate) fn fnd_03_era_classification_planted_negative() {
let baseline_frame = StdioOpeningFrame::ModernRequest {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
};
let mut accepted_classifier = StdioEraClassifier::new(ProtocolPolicy::Auto);
assert_eq!(
accepted_classifier.classify_opening(baseline_frame.clone()),
StdioEraDecision::Selected {
era: ProtocolEra::Modern2026,
modern_version: Some(ModernVersionSupport::Supported),
}
);
let accepted_state = accepted_classifier.state().clone();
let mut planted_classifier = StdioEraClassifier::new(ProtocolPolicy::Auto);
let refusal = planted_classifier.classify_opening(
StdioOpeningFrame::MixedInitializeAndModernMetadata {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
},
);
assert_eq!(
refusal,
StdioEraDecision::RejectedAndClosed {
reason: StdioEraRejection::MixedEraMarkers,
}
);
assert_eq!(
planted_classifier.state(),
&StdioEraState::TerminalWithoutEra
);
assert_eq!(accepted_classifier.state(), &accepted_state);
assert_eq!(
planted_classifier.classify_opening(baseline_frame),
StdioEraDecision::AlreadyTerminal
);
assert_eq!(
planted_classifier.state(),
&StdioEraState::TerminalWithoutEra
);
}
}