mod report;
mod rule;
pub use report::{TranslationReport, TranslationStep};
pub use rule::{DegradeRule, Loss};
use crate::domain::ir::features::FeatureSet;
use crate::domain::ir::tool::ToolResult;
use crate::domain::{DegradePolicy, Feature, FeatureProfile, ProtocolVersion};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LowerError {
#[error("FeatureUnavailable({feature:?}, {negotiated})")]
FeatureUnavailable {
feature: Feature,
negotiated: ProtocolVersion,
},
#[error("policy allows degrading {feature:?} but names no rule for it")]
NoRuleForFeature {
feature: Feature,
},
#[error("`{field}` does not fit {expected} required at {target}: {detail}")]
FieldNotRepresentable {
field: String,
expected: &'static str,
detail: String,
target: ProtocolVersion,
},
}
pub trait Lowering {
type Value;
fn lower(
&self,
value: Self::Value,
target: ProtocolVersion,
policy: DegradePolicy,
report: &mut TranslationReport,
) -> Result<Self::Value, LowerError>;
}
pub fn check_expressible(
required: &FeatureSet,
available: &FeatureProfile,
negotiated: ProtocolVersion,
) -> Result<(), LowerError> {
let available = FeatureSet::union_of(available.iter());
if required.is_subset_of(available) {
return Ok(());
}
let missing = required
.iter()
.find(|feature| !available.contains(*feature));
Err(LowerError::FeatureUnavailable {
feature: missing.unwrap_or(Feature::Tools),
negotiated,
})
}
#[must_use]
pub fn rule_for(feature: Feature, policy: DegradePolicy) -> Option<DegradeRule> {
if !policy.allows(feature) {
return None;
}
DegradeRule::ALL
.iter()
.copied()
.find(|rule| rule.is_fallback_for(feature))
}
pub fn lower_tool_result(
value: ToolResult,
available: &FeatureProfile,
target: ProtocolVersion,
policy: DegradePolicy,
) -> Result<(ToolResult, TranslationReport), LowerError> {
let required = value.required_features();
let available = FeatureSet::union_of(available.iter());
if required.is_subset_of(available) {
return Ok((value, TranslationReport::new(target)));
}
let mut result = value;
let mut report = TranslationReport::new(target);
let mut pending: Vec<DegradeRule> = Vec::new();
for feature in required.iter() {
if available.contains(feature) {
continue;
}
let rule = rule_for(feature, policy).ok_or_else(|| {
if policy.allows(feature) {
LowerError::NoRuleForFeature { feature }
} else {
LowerError::FeatureUnavailable {
feature,
negotiated: target,
}
}
})?;
pending.push(rule);
}
for rule in pending {
match rule {
DegradeRule::StructuredContentToText => {
if result.downgrade_structured_output().is_some() {
report.record(rule);
}
}
DegradeRule::DropAnnotations => {
result = drop_annotations(&result);
report.record(rule);
}
DegradeRule::DropAudio | DegradeRule::DropResourceLink => {
result = drop_blocks(&result, rule);
report.record(rule);
}
DegradeRule::AudioToTextPlaceholder
| DegradeRule::DropImage
| DegradeRule::DropResource => {
if reduce_tool_result(&mut result, rule) {
report.record(rule);
}
}
}
}
Ok((result, report))
}
fn drop_annotations(result: &ToolResult) -> ToolResult {
use crate::domain::ir::content::Content;
let mut result = result.clone();
for block in &mut result.content {
match block {
Content::Text(block) => block.annotations = None,
Content::Image(block) => block.annotations = None,
Content::Audio(block) => block.annotations = None,
Content::ResourceLink(block) => block.annotations = None,
Content::EmbeddedResource(block) => block.annotations = None,
Content::Unknown(_) => {}
}
}
result
}
fn drop_blocks(result: &ToolResult, rule: DegradeRule) -> ToolResult {
use crate::domain::ir::content::Content;
let mut result = result.clone();
result.content.retain(|block| match block {
Content::Audio(_) => !matches!(rule, DegradeRule::DropAudio),
Content::ResourceLink(_) => !matches!(rule, DegradeRule::DropResourceLink),
Content::Text(_)
| Content::Image(_)
| Content::EmbeddedResource(_)
| Content::Unknown(_) => true,
});
result
}
pub fn reduce_tool_result(result: &mut ToolResult, rule: DegradeRule) -> bool {
use crate::domain::ir::content::{Content, TextContent};
let mut replaced = 0_usize;
match rule {
DegradeRule::AudioToTextPlaceholder => {
for block in &mut result.content {
if matches!(block, Content::Audio(_)) {
*block = Content::Text(TextContent::new("[audio omitted]"));
replaced += 1;
}
}
}
DegradeRule::DropImage | DegradeRule::DropResource => {
let before = result.content.len();
result.content.retain(|block| match block {
Content::Image(_) => !matches!(rule, DegradeRule::DropImage),
Content::EmbeddedResource(_) => !matches!(rule, DegradeRule::DropResource),
_ => true,
});
replaced = before - result.content.len();
}
_ => return false,
}
replaced > 0
}
#[cfg(test)]
mod tests {
#![expect(
clippy::panic,
clippy::unwrap_used,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use super::{
DegradeRule, LowerError, Lowering, TranslationReport, check_expressible, lower_tool_result,
reduce_tool_result, rule_for,
};
use crate::domain::ir::lower::Loss;
use crate::domain::ir::{AudioContent, Content, ImageContent, TextContent, ToolResult};
use crate::domain::{
CapabilitySet, DegradePolicy, Feature, FeatureProfile, Negotiation, ProtocolVersion,
TransportKind, resolve,
};
#[test]
fn a_value_that_does_not_fit_is_not_reported_as_a_missing_feature() {
let not_representable = LowerError::FieldNotRepresentable {
field: "io.modelcontextprotocol/serverInfo".to_owned(),
expected: "Implementation",
detail: "missing field `version`".to_owned(),
target: ProtocolVersion::V2026_07_28,
};
let unavailable = LowerError::FeatureUnavailable {
feature: Feature::ToolResultType,
negotiated: ProtocolVersion::V2025_06_18,
};
assert_ne!(not_representable, unavailable);
assert!(not_representable.to_string().contains("serverInfo"));
assert!(!not_representable.to_string().contains("ToolResultType"));
}
#[test]
fn the_unrepresentable_field_error_names_where_it_failed() {
let error = LowerError::FieldNotRepresentable {
field: "io.modelcontextprotocol/serverInfo".to_owned(),
expected: "Implementation",
detail: "missing field `version`".to_owned(),
target: ProtocolVersion::V2026_07_28,
};
let rendered = error.to_string();
assert!(rendered.contains("2026-07-28"), "{rendered}");
assert!(rendered.contains("missing field `version`"), "{rendered}");
}
fn session(version: ProtocolVersion) -> FeatureProfile {
let caps = CapabilitySet::all_for(version);
resolve(Negotiation {
version,
client_caps: caps,
server_caps: caps,
transport: TransportKind::Stdio,
})
}
fn downgrade(feature: Feature) -> DegradePolicy {
DegradePolicy::strict().with(feature)
}
#[test]
fn a_value_that_fits_is_unchanged_and_unreported() {
let value = ToolResult::text("hello");
let (result, report) = lower_tool_result(
value.clone(),
&session(ProtocolVersion::V2025_03_26),
ProtocolVersion::V2025_03_26,
DegradePolicy::strict(),
)
.unwrap();
assert_eq!(result, value);
assert!(report.is_empty());
assert!(report.is_lossless());
}
#[test]
fn structured_output_downlevel_scenario() {
let source = ToolResult::new(vec![Content::Text(TextContent::new("ok"))])
.with_structured(r#"{"temperature":0.7}"#)
.unwrap();
let target = ProtocolVersion::V2025_03_26;
let error = lower_tool_result(
source.clone(),
&session(target),
target,
DegradePolicy::strict(),
)
.unwrap_err();
assert!(matches!(error, LowerError::FeatureUnavailable { .. }));
assert!(
error.to_string().contains("ToolStructuredOutput"),
"{error}"
);
let (result, report) = lower_tool_result(
source,
&session(target),
target,
downgrade(Feature::ToolStructuredOutput),
)
.unwrap();
assert!(result.structured_content.is_none());
assert_eq!(result.content.len(), 2);
assert_eq!(report.len(), 1);
let Some((step, _)) = report.steps().split_first() else {
panic!("expected one step");
};
assert_eq!(step.rule, DegradeRule::StructuredContentToText);
assert!(report.is_lossless(), "the fallback loses nothing");
}
#[test]
fn a_missing_feature_is_reported_with_its_revision() {
let required = crate::domain::ir::FeatureSet::empty().with(Feature::Elicitation);
let error = check_expressible(
&required,
&session(ProtocolVersion::V2025_03_26),
ProtocolVersion::V2025_03_26,
)
.unwrap_err();
assert_eq!(
error,
LowerError::FeatureUnavailable {
feature: Feature::Elicitation,
negotiated: ProtocolVersion::V2025_03_26,
},
);
}
#[test]
fn the_error_message_names_the_feature_and_the_revision() {
let required = crate::domain::ir::FeatureSet::empty().with(Feature::Elicitation);
let error = check_expressible(
&required,
&session(ProtocolVersion::V2025_03_26),
ProtocolVersion::V2025_03_26,
)
.unwrap_err();
assert_eq!(
error.to_string(),
"FeatureUnavailable(Elicitation, 2025-03-26)"
);
}
#[test]
fn the_gate_and_the_value_agree() {
for version in ProtocolVersion::KNOWN {
let available = session(version);
for value in [
ToolResult::text("x"),
ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]),
ToolResult::new(vec![Content::Image(ImageContent::new("A", "image/png"))]),
] {
let gate = check_expressible(&value.required_features(), &available, version);
let lower =
lower_tool_result(value.clone(), &available, version, DegradePolicy::strict());
assert_eq!(
gate.is_ok(),
lower.is_ok(),
"{version} / {:?}: gate said {:?}, lower said {:?}",
value.content.first().map(Content::kind),
gate.err(),
lower.err(),
);
}
}
}
#[test]
fn a_destructive_downgrade_is_recorded_as_destructive() {
let value = ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]);
let target = ProtocolVersion::V2024_11_05;
assert!(
lower_tool_result(
value.clone(),
&session(target),
target,
DegradePolicy::strict()
)
.is_err()
);
let policy = downgrade(Feature::ToolAudioContent);
let (result, report) = lower_tool_result(value, &session(target), target, policy).unwrap();
assert!(result.content.is_empty(), "the block was dropped");
assert_eq!(report.worst_loss(), Loss::Destructive);
assert!(!report.is_lossless_or_flagged());
}
#[test]
fn a_destructive_placeholder_is_opt_in_only() {
let mut value = ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]);
assert!(reduce_tool_result(
&mut value,
DegradeRule::AudioToTextPlaceholder
));
assert_eq!(value.content.len(), 1);
let Some(block) = value.content.first() else {
panic!("expected one block");
};
assert_eq!(block.kind(), "text");
}
#[test]
fn a_reduction_that_changes_nothing_says_so() {
let mut value = ToolResult::text("x");
assert!(!reduce_tool_result(
&mut value,
DegradeRule::AudioToTextPlaceholder
));
assert!(!reduce_tool_result(&mut value, DegradeRule::DropImage));
assert_eq!(value.content.len(), 1);
}
#[test]
fn asking_for_a_fallback_as_a_reduction_does_nothing() {
let mut value = ToolResult::text("x").with_structured(r#"{"a":1}"#).unwrap();
assert!(!reduce_tool_result(
&mut value,
DegradeRule::StructuredContentToText
));
assert!(value.structured_content.is_some(), "not applied");
}
#[test]
fn rule_lookup_requires_the_policy_to_name_the_feature() {
assert!(rule_for(Feature::ToolStructuredOutput, DegradePolicy::strict()).is_none());
assert_eq!(
rule_for(
Feature::ToolStructuredOutput,
downgrade(Feature::ToolStructuredOutput)
),
Some(DegradeRule::StructuredContentToText),
);
}
#[test]
fn a_permissive_policy_never_hits_the_no_rule_error() {
let value = ToolResult::new(vec![Content::Audio(AudioContent::new("A", "audio/wav"))]);
let result = lower_tool_result(
value,
&session(ProtocolVersion::V2024_11_05),
ProtocolVersion::V2024_11_05,
DegradePolicy::permissive(),
);
assert!(result.is_ok());
}
struct SessionLowering {
available: FeatureProfile,
}
impl Lowering for SessionLowering {
type Value = ToolResult;
fn lower(
&self,
value: ToolResult,
target: ProtocolVersion,
policy: DegradePolicy,
report: &mut TranslationReport,
) -> Result<ToolResult, LowerError> {
let (lowered, own) = lower_tool_result(value, &self.available, target, policy)?;
report.absorb(&own);
Ok(lowered)
}
}
#[test]
fn a_port_implementor_reports_into_the_callers_report() {
let target = ProtocolVersion::V2025_03_26;
let codec = SessionLowering {
available: session(target),
};
let value = ToolResult::text("ok")
.with_structured(r#"{"a":1}"#)
.unwrap();
let mut report = TranslationReport::new(target);
let lowered = codec
.lower(
value,
target,
downgrade(Feature::ToolStructuredOutput),
&mut report,
)
.unwrap();
assert!(lowered.structured_content.is_none());
assert_eq!(report.len(), 1);
assert!(report.is_lossless());
}
#[test]
fn a_port_implementor_errors_rather_than_returning_a_lossy_value() {
let target = ProtocolVersion::V2025_03_26;
let codec = SessionLowering {
available: session(target),
};
let value = ToolResult::text("ok")
.with_structured(r#"{"a":1}"#)
.unwrap();
let mut report = TranslationReport::new(target);
let result = codec.lower(value, target, DegradePolicy::strict(), &mut report);
assert!(result.is_err());
assert!(report.is_empty());
}
#[test]
fn every_applied_rule_is_recorded() {
let value = ToolResult::new(vec![
Content::Audio(AudioContent::new("A", "audio/wav")),
Content::Image(ImageContent::new("B", "image/png")),
])
.with_structured(r#"{"a":1}"#)
.unwrap();
let target = ProtocolVersion::V2024_11_05;
let policy = DegradePolicy::permissive();
let (result, report) = lower_tool_result(value, &session(target), target, policy).unwrap();
assert!(report.len() >= 2, "{report:?}");
assert!(!report.is_lossless_or_flagged());
assert!(result.structured_content.is_none());
assert!(!result.content.is_empty(), "the text fallback survived");
}
}