use std::fmt;
use fastmcp_protocol::protocol_policy::{
HttpEndpointBundle, HttpEraCache, HttpModernProbe, HttpProbeBody, ProtocolEra, ProtocolPolicy,
};
use crate::ClientProtocolPlan;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientHttpNegotiationState {
probe_dispatched: bool,
selected_era: Option<ProtocolEra>,
legacy_sse_fallback_authorized: bool,
}
impl ClientHttpNegotiationState {
#[must_use]
pub const fn probe_dispatched(self) -> bool {
self.probe_dispatched
}
#[must_use]
pub const fn selected_era(self) -> Option<ProtocolEra> {
self.selected_era
}
#[must_use]
pub const fn legacy_sse_fallback_authorized(self) -> bool {
self.legacy_sse_fallback_authorized
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientHttpNegotiationDecision {
ModernSelected,
LegacySseFallbackAuthorized,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientHttpNegotiationError {
MissingHttpEndpointBundle { policy: ProtocolPolicy },
ModernProbeForbiddenForLegacyOnly,
ModernProbeAlreadyDispatched,
ModernProbeTransportFailure,
ModernProbeRejectedWithoutLegacyFallback { status: u16, body: HttpProbeBody },
FeatureConfigurationUnavailable { policy: ProtocolPolicy },
}
impl fmt::Display for ClientHttpNegotiationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingHttpEndpointBundle { policy } => {
write!(
formatter,
"{policy:?} has no configured HTTP endpoint bundle"
)
}
Self::ModernProbeForbiddenForLegacyOnly => formatter
.write_str("legacy-only HTTP must use the installed legacy adapter directly"),
Self::ModernProbeAlreadyDispatched => {
formatter.write_str("the modern HTTP probe was already dispatched for this attempt")
}
Self::ModernProbeTransportFailure => {
formatter.write_str("modern HTTP probe transport failure cannot authorize fallback")
}
Self::ModernProbeRejectedWithoutLegacyFallback { status, body } => write!(
formatter,
"modern HTTP probe status {status} with {body:?} cannot authorize legacy fallback"
),
Self::FeatureConfigurationUnavailable { policy } => write!(
formatter,
"{policy:?} requires a policy or extension this crate build did not include"
),
}
}
}
impl std::error::Error for ClientHttpNegotiationError {}
#[derive(Debug)]
pub struct ClientHttpNegotiation {
policy: ProtocolPolicy,
bundle: HttpEndpointBundle,
cache: HttpEraCache,
state: ClientHttpNegotiationState,
}
impl ClientHttpNegotiation {
pub fn from_protocol_plan(
protocol_plan: &ClientProtocolPlan,
) -> Result<Self, ClientHttpNegotiationError> {
let policy = protocol_plan.policy();
let Some(bundle) = protocol_plan.http_endpoints() else {
return Err(ClientHttpNegotiationError::MissingHttpEndpointBundle { policy });
};
Ok(Self {
policy,
bundle: bundle.clone(),
cache: HttpEraCache::default(),
state: ClientHttpNegotiationState {
probe_dispatched: false,
selected_era: None,
legacy_sse_fallback_authorized: false,
},
})
}
#[must_use]
pub const fn state(&self) -> ClientHttpNegotiationState {
self.state
}
pub fn observe_modern_probe(
&mut self,
probe: HttpModernProbe,
) -> Result<ClientHttpNegotiationDecision, ClientHttpNegotiationError> {
if self.state.probe_dispatched {
return Err(ClientHttpNegotiationError::ModernProbeAlreadyDispatched);
}
self.state.probe_dispatched = true;
let decision = self.preflight_probe(probe)?;
match decision {
ClientHttpNegotiationDecision::ModernSelected => {
let _ = self.cache.classify_or_cached(&self.bundle, probe);
self.state.selected_era = Some(ProtocolEra::Modern2026);
}
ClientHttpNegotiationDecision::LegacySseFallbackAuthorized => {
self.state.legacy_sse_fallback_authorized = true;
}
}
Ok(decision)
}
fn preflight_probe(
&self,
probe: HttpModernProbe,
) -> Result<ClientHttpNegotiationDecision, ClientHttpNegotiationError> {
match self.policy {
ProtocolPolicy::LegacyOnly => {
Err(ClientHttpNegotiationError::ModernProbeForbiddenForLegacyOnly)
}
ProtocolPolicy::ModernOnly | ProtocolPolicy::Auto
if matches!(probe.body, HttpProbeBody::RecognizedModernJsonRpc) =>
{
Ok(ClientHttpNegotiationDecision::ModernSelected)
}
ProtocolPolicy::Auto
if matches!(probe.status, 400 | 404 | 405)
&& matches!(
probe.body,
HttpProbeBody::Empty | HttpProbeBody::Unrecognized
) =>
{
Ok(ClientHttpNegotiationDecision::LegacySseFallbackAuthorized)
}
ProtocolPolicy::ModernOnly | ProtocolPolicy::Auto
if matches!(probe.body, HttpProbeBody::TransportFailure) =>
{
Err(ClientHttpNegotiationError::ModernProbeTransportFailure)
}
ProtocolPolicy::ModernOnly | ProtocolPolicy::Auto => Err(
ClientHttpNegotiationError::ModernProbeRejectedWithoutLegacyFallback {
status: probe.status,
body: probe.body,
},
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fastmcp_core::CanonicalHttpUrl;
fn modern_only_plan() -> ClientProtocolPlan {
ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(CanonicalHttpUrl::parse("https://api.example.test/mcp").unwrap()),
None,
None,
"credential-partition".to_owned(),
"security-partition".to_owned(),
"http-test".to_owned(),
1,
1,
1,
)
.expect("a modern-only plan needs only its modern target")
}
fn legacy_only_plan() -> ClientProtocolPlan {
ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(CanonicalHttpUrl::parse("https://api.example.test/sse").unwrap()),
Some(CanonicalHttpUrl::parse("https://api.example.test/messages").unwrap()),
"credential-partition".to_owned(),
"security-partition".to_owned(),
"http-test".to_owned(),
1,
1,
1,
)
.expect("a legacy-only plan needs only its exact legacy targets")
}
#[test]
fn modern_only_rejects_one_unrecognized_probe_without_selecting_or_retrying() {
let mut negotiation = ClientHttpNegotiation::from_protocol_plan(&modern_only_plan())
.expect("the configured plan creates a negotiation attempt");
let error = negotiation
.observe_modern_probe(HttpModernProbe {
status: 404,
body: HttpProbeBody::Unrecognized,
})
.expect_err("changing only the probe body must not select modern");
assert_eq!(
error,
ClientHttpNegotiationError::ModernProbeRejectedWithoutLegacyFallback {
status: 404,
body: HttpProbeBody::Unrecognized,
}
);
assert!(negotiation.state().probe_dispatched());
assert_eq!(negotiation.state().selected_era(), None);
assert!(!negotiation.state().legacy_sse_fallback_authorized());
assert_eq!(
negotiation.observe_modern_probe(HttpModernProbe {
status: 200,
body: HttpProbeBody::RecognizedModernJsonRpc,
}),
Err(ClientHttpNegotiationError::ModernProbeAlreadyDispatched)
);
}
#[test]
fn legacy_only_keeps_its_modern_probe_refusal_distinct_from_feature_admission() {
let mut negotiation = ClientHttpNegotiation::from_protocol_plan(&legacy_only_plan())
.expect("the configured legacy plan creates a negotiation attempt");
assert_eq!(
negotiation.observe_modern_probe(HttpModernProbe {
status: 200,
body: HttpProbeBody::RecognizedModernJsonRpc,
}),
Err(ClientHttpNegotiationError::ModernProbeForbiddenForLegacyOnly),
"LegacyOnly rejects a modern probe for its own reason"
);
}
}