use crate::domain::Feature;
use crate::domain::ir::extensions::Extensions;
use crate::domain::ir::features::FeatureSet;
use serde_json::Value as JsonValue;
use crate::domain::ir::json::JsonObject;
#[derive(Debug, Clone, PartialEq)]
pub enum Content {
Text(TextContent),
Image(ImageContent),
Audio(AudioContent),
ResourceLink(ResourceLinkContent),
EmbeddedResource(EmbeddedResource),
Unknown(UnknownContent),
}
impl Default for Content {
fn default() -> Self {
Self::Text(TextContent::default())
}
}
impl TextContent {
#[must_use]
pub const fn required_features(&self) -> FeatureSet {
FeatureSet::empty()
}
}
impl ImageContent {
#[must_use]
pub const fn required_features(&self) -> FeatureSet {
FeatureSet::empty()
}
}
impl AudioContent {
#[must_use]
pub fn required_features(&self) -> FeatureSet {
FeatureSet::empty().with(Feature::ToolAudioContent)
}
}
impl ResourceLinkContent {
#[must_use]
pub fn required_features(&self) -> FeatureSet {
let mut required = FeatureSet::empty().with(Feature::ToolResourceLinks);
if !self.icons.is_empty() {
required = required.with(Feature::ToolResourceLinkIcons);
}
required
}
}
impl EmbeddedResource {
#[must_use]
pub const fn required_features(&self) -> FeatureSet {
FeatureSet::empty()
}
}
impl Annotations {
#[must_use]
pub fn required_features(&self) -> FeatureSet {
if self.last_modified.is_some() {
FeatureSet::empty().with(Feature::ToolAnnotationsLastModified)
} else {
FeatureSet::empty()
}
}
}
impl Content {
#[must_use]
pub fn required_features(&self) -> FeatureSet {
let mut required = match self {
Self::Text(text) => text.required_features(),
Self::Image(image) => image.required_features(),
Self::Audio(audio) => audio.required_features(),
Self::ResourceLink(link) => link.required_features(),
Self::EmbeddedResource(resource) => resource.required_features(),
Self::Unknown(_) => FeatureSet::empty(),
};
if let Some(annotations) = self.annotations() {
required = required.union(annotations.required_features());
}
required
}
#[must_use]
pub const fn annotations(&self) -> Option<&Annotations> {
match self {
Self::Text(block) => block.annotations.as_ref(),
Self::Image(block) => block.annotations.as_ref(),
Self::Audio(block) => block.annotations.as_ref(),
Self::ResourceLink(block) => block.annotations.as_ref(),
Self::EmbeddedResource(block) => block.annotations.as_ref(),
Self::Unknown(_) => None,
}
}
#[must_use]
pub fn type_name(&self) -> &str {
match self {
Self::Text(_) => "text",
Self::Image(_) => "image",
Self::Audio(_) => "audio",
Self::ResourceLink(_) => "resource_link",
Self::EmbeddedResource(_) => "resource",
Self::Unknown(unknown) => &unknown.type_name,
}
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Text(_) => "text",
Self::Image(_) => "image",
Self::Audio(_) => "audio",
Self::ResourceLink(_) => "resourceLink",
Self::EmbeddedResource(_) => "embeddedResource",
Self::Unknown(_) => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TextContent {
pub text: String,
pub annotations: Option<Annotations>,
pub extensions: Extensions,
}
impl TextContent {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
annotations: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageContent {
pub data: String,
pub mime_type: String,
pub annotations: Option<Annotations>,
pub extensions: Extensions,
}
impl ImageContent {
#[must_use]
pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
data: data.into(),
mime_type: mime_type.into(),
annotations: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioContent {
pub data: String,
pub mime_type: String,
pub annotations: Option<Annotations>,
pub extensions: Extensions,
}
impl AudioContent {
#[must_use]
pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
data: data.into(),
mime_type: mime_type.into(),
annotations: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResourceLinkContent {
pub uri: String,
pub name: String,
pub description: Option<String>,
pub mime_type: Option<String>,
pub size: Option<u64>,
pub icons: Vec<ResourceIcon>,
pub title: Option<String>,
pub annotations: Option<Annotations>,
pub extensions: Extensions,
}
impl ResourceLinkContent {
#[must_use]
pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri: uri.into(),
name: name.into(),
description: None,
mime_type: None,
size: None,
icons: Vec::new(),
title: None,
annotations: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResourceIcon {
pub src: String,
pub mime_type: Option<String>,
pub sizes: Vec<String>,
pub theme: Option<JsonValue>,
pub extensions: Extensions,
}
impl ResourceIcon {
#[must_use]
pub fn new(src: impl Into<String>) -> Self {
Self {
src: src.into(),
mime_type: None,
sizes: Vec::new(),
theme: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EmbeddedResource {
pub text: Option<String>,
pub blob: Option<String>,
pub uri: String,
pub mime_type: Option<String>,
pub annotations: Option<Annotations>,
pub extensions: Extensions,
}
impl EmbeddedResource {
#[must_use]
pub fn text(uri: impl Into<String>, text: impl Into<String>) -> Self {
Self {
text: Some(text.into()),
blob: None,
uri: uri.into(),
mime_type: None,
annotations: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Annotations {
pub audience: Vec<Role>,
pub priority: Option<f64>,
pub last_modified: Option<String>,
pub extensions: Extensions,
}
impl Annotations {
#[must_use]
pub const fn empty() -> Self {
Self {
audience: Vec::new(),
priority: None,
last_modified: None,
extensions: Extensions::empty(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Role {
User,
Assistant,
}
impl Role {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
}
}
}
impl core::str::FromStr for Role {
type Err = UnknownRole;
fn from_str(text: &str) -> Result<Self, Self::Err> {
match text {
"user" => Ok(Self::User),
"assistant" => Ok(Self::Assistant),
other => Err(UnknownRole {
role: other.to_owned(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown content role {role:?}")]
pub struct UnknownRole {
pub role: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnknownContent {
pub type_name: String,
pub raw: JsonObject,
pub extensions: Extensions,
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
)]
use super::{
Annotations, AudioContent, Content, EmbeddedResource, ImageContent, ResourceIcon,
ResourceLinkContent, Role, TextContent, UnknownContent,
};
use crate::domain::Feature;
use crate::domain::ir::extensions::{ExtensionValue, Extensions};
use crate::domain::ir::json::JsonObject;
#[test]
fn required_features_per_variant() {
let cases: [(Content, &[Feature]); 6] = [
(Content::Text(TextContent::new("x")), &[]),
(Content::Image(ImageContent::new("AAAA", "image/png")), &[]),
(
Content::Audio(AudioContent::new("AAAA", "audio/wav")),
&[Feature::ToolAudioContent],
),
(
Content::ResourceLink(ResourceLinkContent::new("file:///a", "a")),
&[Feature::ToolResourceLinks],
),
(
Content::EmbeddedResource(EmbeddedResource::text("file:///a", "x")),
&[],
),
(
Content::Unknown(UnknownContent {
type_name: "video".into(),
raw: JsonObject::new(r#"{"type":"video"}"#).unwrap(),
extensions: Extensions::empty(),
}),
&[],
),
];
for (content, expected_features) in cases {
let required = content.required_features();
let expected_count = u32::try_from(expected_features.len()).unwrap_or(u32::MAX);
assert_eq!(
required.len(),
expected_count,
"{}: {required:?}",
content.kind(),
);
for feature in expected_features {
assert!(
required.contains(*feature),
"{} should need {feature}",
content.kind(),
);
}
}
}
#[test]
fn required_features_are_turned_on_by_the_revision_that_defines_them() {
for (content, version) in [
(
Content::Audio(AudioContent::new("AAAA", "audio/wav")),
crate::domain::ProtocolVersion::V2025_03_26,
),
(
Content::ResourceLink(ResourceLinkContent::new("file:///a", "a")),
crate::domain::ProtocolVersion::V2025_06_18,
),
] {
for feature in content.required_features().iter() {
let caps = crate::domain::CapabilitySet::all_for(version);
let profile = crate::domain::resolve(crate::domain::Negotiation {
version,
client_caps: caps,
server_caps: caps,
transport: crate::domain::TransportKind::Stdio,
});
assert!(
profile.allows(feature),
"{version} defines {feature} but a full profile omits it",
);
}
}
}
#[test]
fn type_names_match_the_wire() {
assert_eq!(Content::Text(TextContent::new("x")).type_name(), "text");
assert_eq!(
Content::Image(ImageContent::new("A", "image/png")).type_name(),
"image"
);
assert_eq!(
Content::Audio(AudioContent::new("A", "audio/wav")).type_name(),
"audio"
);
assert_eq!(
Content::ResourceLink(ResourceLinkContent::new("u", "n")).type_name(),
"resource_link"
);
assert_eq!(
Content::EmbeddedResource(EmbeddedResource::text("u", "t")).type_name(),
"resource"
);
assert_eq!(
Content::Unknown(UnknownContent {
type_name: "video".into(),
raw: JsonObject::new("{}").unwrap(),
extensions: Extensions::empty(),
})
.type_name(),
"video",
);
}
#[test]
fn text_defaults_to_an_empty_string() {
assert_eq!(Content::default(), Content::Text(TextContent::new("")));
}
#[test]
fn plain_annotations_do_not_raise_the_requirement() {
let plain = Content::Text(TextContent::new("x"));
let annotated = Content::Text(TextContent {
annotations: Some(Annotations {
audience: vec![Role::User, Role::Assistant],
priority: Some(0.5),
..Annotations::empty()
}),
..TextContent::new("x")
});
assert_eq!(plain.required_features(), annotated.required_features());
}
#[test]
fn last_modified_raises_the_requirement() {
let mut annotations = Annotations::empty();
annotations.audience = vec![Role::User];
assert!(annotations.required_features().is_empty());
annotations.last_modified = Some("2025-06-18T00:00:00Z".to_owned());
assert!(
annotations
.required_features()
.contains(Feature::ToolAnnotationsLastModified)
);
}
#[test]
#[expect(
clippy::cast_possible_truncation,
reason = "the lossy cast is the subject of the test"
)]
#[expect(clippy::float_cmp, reason = "exact equality is the claim being made")]
fn priority_is_not_narrowed_to_f32() {
for value in [0.1_f64, 0.3, 0.7] {
assert_ne!(
f64::from(value as f32),
value,
"{value} is exactly representable in f32, so this test no longer \
demonstrates why the field is f64"
);
}
}
#[test]
fn icons_raise_a_link_s_requirement() {
let bare = ResourceLinkContent::new("file:///a", "a");
assert!(
!bare
.required_features()
.contains(Feature::ToolResourceLinkIcons)
);
let with_icons = ResourceLinkContent {
icons: vec![ResourceIcon::new("data:image/png;base64,AAA=")],
..ResourceLinkContent::new("file:///a", "a")
};
assert!(
with_icons
.required_features()
.contains(Feature::ToolResourceLinkIcons)
);
assert!(
with_icons
.required_features()
.contains(Feature::ToolResourceLinks)
);
}
#[test]
fn extensions_ride_along_without_affecting_requirements() {
let content = Content::Text(TextContent {
extensions: Extensions::empty().with("vendor.x", ExtensionValue::Bool(true)),
..TextContent::new("x")
});
assert!(content.required_features().is_empty());
}
#[test]
fn roles_round_trip_through_their_wire_spelling() {
for role in [Role::User, Role::Assistant] {
assert_eq!(role.as_str().parse::<Role>().unwrap(), role);
}
assert!("teapot".parse::<Role>().is_err());
}
#[test]
fn binary_content_builders_do_not_set_annotations() {
let image = ImageContent::new("AAAA", "image/png");
assert!(image.annotations.is_none());
assert_eq!(image.mime_type, "image/png");
let audio = AudioContent::new("BBBB", "audio/wav");
assert!(audio.annotations.is_none());
assert_eq!(audio.data, "BBBB");
}
#[test]
fn resource_link_optional_fields_default_to_none() {
let link = ResourceLinkContent::new("file:///x", "x");
assert_eq!(link.uri, "file:///x");
assert_eq!(link.name, "x");
assert!(link.description.is_none());
assert!(link.mime_type.is_none());
assert!(link.size.is_none());
}
#[test]
fn an_embedded_text_resource_has_no_blob() {
let resource = EmbeddedResource::text("file:///x", "body");
assert_eq!(resource.text.as_deref(), Some("body"));
assert!(resource.blob.is_none());
assert_eq!(resource.uri, "file:///x");
}
}