use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct Annotations {
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
}
impl Annotations {
pub fn new() -> Self {
Self::default()
}
pub fn with_audience(mut self, audience: Vec<String>) -> Self {
self.audience = Some(audience);
self
}
pub fn with_priority(mut self, priority: f64) -> Self {
self.priority = Some(priority);
self
}
pub fn with_last_modified(mut self, last_modified: impl Into<String>) -> Self {
self.last_modified = Some(last_modified.into());
self
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "WireContentIn")]
pub enum Content {
Text {
text: String,
},
Image {
data: String,
mime_type: String,
},
#[non_exhaustive]
Resource {
uri: String,
text: Option<String>,
blob: Option<String>,
mime_type: Option<String>,
annotations: Option<Annotations>,
meta: Option<serde_json::Map<String, serde_json::Value>>,
},
Audio {
data: String,
mime_type: String,
annotations: Option<Annotations>,
meta: Option<serde_json::Map<String, Value>>,
},
ResourceLink(Box<ResourceLinkContent>),
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireResourceContentsOut<'a> {
uri: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: &'a Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
blob: Option<&'a str>,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum WireContentOut<'a> {
#[serde(rename_all = "camelCase")]
Text { text: &'a str },
#[serde(rename_all = "camelCase")]
Image { data: &'a str, mime_type: &'a str },
#[serde(rename_all = "camelCase")]
Resource {
resource: WireResourceContentsOut<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
annotations: &'a Option<Annotations>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: &'a Option<serde_json::Map<String, Value>>,
},
#[serde(rename = "audio", rename_all = "camelCase")]
Audio {
data: &'a str,
mime_type: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
annotations: &'a Option<Annotations>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: &'a Option<serde_json::Map<String, Value>>,
},
#[serde(rename = "resource_link")]
ResourceLink(&'a ResourceLinkContent),
}
fn embedded_resource_union<'a>(
text: Option<&'a str>,
blob: Option<&'a str>,
) -> (Option<&'a str>, Option<&'a str>) {
match (text, blob) {
(Some(text), _) => (Some(text), None),
(None, Some(blob)) => (None, Some(blob)),
(None, None) => (Some(""), None),
}
}
fn content_as_wire(content: &Content) -> WireContentOut<'_> {
match content {
Content::Text { text } => WireContentOut::Text { text },
Content::Image { data, mime_type } => WireContentOut::Image { data, mime_type },
Content::Resource {
uri,
text,
blob,
mime_type,
annotations,
meta,
} => {
let (text, blob) = embedded_resource_union(text.as_deref(), blob.as_deref());
WireContentOut::Resource {
resource: WireResourceContentsOut {
uri,
mime_type,
text,
blob,
},
annotations,
meta,
}
},
Content::Audio {
data,
mime_type,
annotations,
meta,
} => WireContentOut::Audio {
data,
mime_type,
annotations,
meta,
},
Content::ResourceLink(link) => WireContentOut::ResourceLink(link),
}
}
impl Serialize for Content {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
content_as_wire(self).serialize(serializer)
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireResourceContentsIn {
uri: String,
mime_type: Option<String>,
text: Option<String>,
blob: Option<String>,
#[serde(rename = "_meta")]
meta: Option<serde_json::Map<String, Value>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireResourceIn {
resource: Option<WireResourceContentsIn>,
uri: Option<String>,
text: Option<String>,
blob: Option<String>,
mime_type: Option<String>,
annotations: Option<Annotations>,
#[serde(rename = "_meta")]
meta: Option<serde_json::Map<String, Value>>,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum WireContentIn {
#[serde(rename_all = "camelCase")]
Text {
text: String,
},
#[serde(rename_all = "camelCase")]
Image {
data: String,
mime_type: String,
},
Resource(Box<WireResourceIn>),
#[serde(rename = "audio", rename_all = "camelCase")]
Audio {
data: String,
mime_type: String,
annotations: Option<Annotations>,
#[serde(rename = "_meta")]
meta: Option<serde_json::Map<String, Value>>,
},
#[serde(rename = "resource_link")]
ResourceLink(Box<ResourceLinkContent>),
}
#[derive(Debug)]
enum ContentWireError {
MissingResourcePayload,
TextAndBlob,
}
impl std::fmt::Display for ContentWireError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingResourcePayload => f.write_str(
"an embedded resource must carry either the spec's nested `resource` object \
(schema.ts:1734-1748) or pmcp's legacy flat fields: missing field `uri`",
),
Self::TextAndBlob => f.write_str(
"an embedded resource carries BOTH `text` and `blob`, but \
`TextResourceContents | BlobResourceContents` (schema.ts:1535, schema.ts:1548) \
is an XOR: a payload carrying both is out of contract and is rejected",
),
}
}
}
fn content_from_wire_resource(wire: WireResourceIn) -> Result<Content, ContentWireError> {
let WireResourceIn {
resource,
uri,
text,
blob,
mime_type,
annotations,
meta,
} = wire;
let (uri, mime_type, text, blob, nested_meta) = match resource {
Some(nested) => (
nested.uri,
nested.mime_type,
nested.text,
nested.blob,
nested.meta,
),
None => (
uri.ok_or(ContentWireError::MissingResourcePayload)?,
mime_type,
text,
blob,
None,
),
};
if text.is_some() && blob.is_some() {
return Err(ContentWireError::TextAndBlob);
}
Ok(Content::Resource {
uri,
text,
blob,
mime_type,
annotations,
meta: meta.or(nested_meta),
})
}
impl TryFrom<WireContentIn> for Content {
type Error = ContentWireError;
fn try_from(wire: WireContentIn) -> Result<Self, Self::Error> {
Ok(match wire {
WireContentIn::Text { text } => Self::Text { text },
WireContentIn::Image { data, mime_type } => Self::Image { data, mime_type },
WireContentIn::Resource(resource) => content_from_wire_resource(*resource)?,
WireContentIn::Audio {
data,
mime_type,
annotations,
meta,
} => Self::Audio {
data,
mime_type,
annotations,
meta,
},
WireContentIn::ResourceLink(link) => Self::ResourceLink(link),
})
}
}
impl Content {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self::Image {
data: data.into(),
mime_type: mime_type.into(),
}
}
#[deprecated(
since = "2.19.0",
note = "a URI-only value cannot be a spec-valid EmbeddedResource; use \
Content::resource_with_text / Content::resource_with_blob for embedded \
content, or Content::resource_link for a reference"
)]
pub fn resource(uri: impl Into<String>) -> Self {
Self::Resource {
uri: uri.into(),
text: None,
blob: None,
mime_type: None,
annotations: None,
meta: None,
}
}
pub fn resource_with_text(
uri: impl Into<String>,
text: impl Into<String>,
mime_type: impl Into<String>,
) -> Self {
Self::Resource {
uri: uri.into(),
text: Some(text.into()),
blob: None,
mime_type: Some(mime_type.into()),
annotations: None,
meta: None,
}
}
pub fn resource_with_blob(
uri: impl Into<String>,
blob: impl Into<String>,
mime_type: impl Into<String>,
) -> Self {
Self::Resource {
uri: uri.into(),
text: None,
blob: Some(blob.into()),
mime_type: Some(mime_type.into()),
annotations: None,
meta: None,
}
}
#[must_use]
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
match &mut self {
Self::Resource {
annotations: slot, ..
}
| Self::Audio {
annotations: slot, ..
} => *slot = Some(annotations),
Self::ResourceLink(link) => link.annotations = Some(annotations),
Self::Text { .. } | Self::Image { .. } => {},
}
self
}
#[must_use]
pub fn with_meta(mut self, meta: serde_json::Map<String, Value>) -> Self {
match &mut self {
Self::Resource { meta: slot, .. } | Self::Audio { meta: slot, .. } => {
*slot = Some(meta);
},
Self::ResourceLink(link) => link.meta = Some(meta),
Self::Text { .. } | Self::Image { .. } => {},
}
self
}
pub fn audio(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self::Audio {
data: data.into(),
mime_type: mime_type.into(),
annotations: None,
meta: None,
}
}
pub fn resource_link(name: impl Into<String>, uri: impl Into<String>) -> Self {
Self::ResourceLink(Box::new(ResourceLinkContent::new(name, uri)))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct ResourceLinkContent {
pub name: String,
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<super::protocol::IconInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Map<String, Value>>,
}
impl ResourceLinkContent {
pub fn new(name: impl Into<String>, uri: impl Into<String>) -> Self {
Self {
name: name.into(),
uri: uri.into(),
title: None,
description: None,
mime_type: None,
icons: None,
annotations: None,
meta: None,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
pub fn with_icons(mut self, icons: Vec<super::protocol::IconInfo>) -> Self {
self.icons = Some(icons);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
pub fn with_meta(mut self, meta: serde_json::Map<String, Value>) -> Self {
self.meta = Some(meta);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
System,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User => write!(f, "user"),
Self::Assistant => write!(f, "assistant"),
Self::System => write!(f, "system"),
}
}
}
#[allow(clippy::redundant_pub_crate)]
pub(crate) mod resource_contents_serde {
use super::Content;
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub(crate) fn serialize<S>(contents: &[Content], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = serializer.serialize_seq(Some(contents.len()))?;
for content in contents {
match content {
Content::Resource {
uri,
text,
blob,
mime_type,
meta,
..
} => {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Rc<'a> {
uri: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: &'a Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
text: &'a Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
blob: &'a Option<String>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: &'a Option<serde_json::Map<String, serde_json::Value>>,
}
seq.serialize_element(&Rc {
uri,
mime_type,
text,
blob,
meta,
})?;
},
Content::Text { text } => {
#[derive(Serialize)]
struct Tc<'a> {
text: &'a str,
}
seq.serialize_element(&Tc { text })?;
},
other @ (Content::Image { .. }
| Content::Audio { .. }
| Content::ResourceLink { .. }) => {
seq.serialize_element(other)?;
},
}
}
seq.end()
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Content>, D::Error>
where
D: Deserializer<'de>,
{
let values: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
let mut contents = Vec::with_capacity(values.len());
for value in values {
if value.get("type").is_some() {
contents.push(
serde_json::from_value::<Content>(value).map_err(serde::de::Error::custom)?,
);
} else if let Some(uri) = value.get("uri").and_then(|v| v.as_str()) {
let text = value.get("text").and_then(|v| v.as_str()).map(String::from);
let blob = value.get("blob").and_then(|v| v.as_str()).map(String::from);
let mime_type = value
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from);
let meta = value.get("_meta").and_then(|v| v.as_object()).cloned();
contents.push(Content::Resource {
uri: uri.to_string(),
text,
blob,
mime_type,
annotations: None,
meta,
});
} else if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
contents.push(Content::Text {
text: text.to_string(),
});
}
}
Ok(contents)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn serialize_content() {
let content = Content::text("Hello");
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "Hello");
}
#[test]
fn test_content_resource_meta_serialization() {
let mut meta_map = serde_json::Map::new();
meta_map.insert(
"widgetDescription".to_string(),
serde_json::Value::String("A chess board widget".to_string()),
);
let content = Content::Resource {
uri: "ui://chess/board".to_string(),
text: Some("<html>chess</html>".to_string()),
blob: None,
mime_type: Some("text/html;profile=mcp-app".to_string()),
annotations: None,
meta: Some(meta_map),
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["_meta"]["widgetDescription"], "A chess board widget");
assert_eq!(json["resource"]["uri"], "ui://chess/board");
assert!(
json.get("uri").is_none(),
"the flat top-level `uri` is the G-1 defect and must be gone"
);
}
#[test]
fn test_content_resource_no_meta_serialization() {
let content = Content::resource_with_text("file:///test.txt", "hello", "text/plain");
let json = serde_json::to_value(&content).unwrap();
assert!(json.get("_meta").is_none());
assert_eq!(json["resource"]["uri"], "file:///test.txt");
assert!(
json.get("uri").is_none(),
"the flat top-level `uri` is the G-1 defect and must be gone"
);
}
#[test]
fn test_content_resource_meta_deserialization() {
let json = json!({
"type": "resource",
"uri": "ui://widget",
"text": "<html></html>",
"mimeType": "text/html",
"_meta": {
"widgetDescription": "test widget",
"csp": { "connectDomains": ["https://api.example.com"] }
}
});
let content: Content = serde_json::from_value(json).unwrap();
match content {
Content::Resource { uri, meta, .. } => {
assert_eq!(uri, "ui://widget");
let meta = meta.unwrap();
assert_eq!(meta["widgetDescription"], "test widget");
assert!(meta.contains_key("csp"));
},
_ => panic!("Expected Content::Resource"),
}
}
#[test]
fn test_content_resource_backward_compat() {
let json = json!({
"type": "resource",
"uri": "file:///old.txt",
"text": "old content",
"mimeType": "text/plain"
});
let content: Content = serde_json::from_value(json).unwrap();
match content {
Content::Resource { uri, meta, .. } => {
assert_eq!(uri, "file:///old.txt");
assert!(meta.is_none());
},
_ => panic!("Expected Content::Resource"),
}
}
#[test]
fn test_audio_content_serialization_roundtrip() {
let content = Content::Audio {
data: "base64audiodata==".to_string(),
mime_type: "audio/wav".to_string(),
annotations: Some(Annotations::new().with_priority(0.8)),
meta: None,
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "audio");
assert_eq!(json["data"], "base64audiodata==");
assert_eq!(json["mimeType"], "audio/wav");
assert_eq!(json["annotations"]["priority"], 0.8);
let roundtrip: Content = serde_json::from_value(json).unwrap();
match roundtrip {
Content::Audio {
data, mime_type, ..
} => {
assert_eq!(data, "base64audiodata==");
assert_eq!(mime_type, "audio/wav");
},
_ => panic!("Expected Content::Audio"),
}
}
#[test]
fn test_resource_link_content_serialization_roundtrip() {
let content = Content::ResourceLink(Box::new(
ResourceLinkContent::new("my-file", "file:///path/to/file.txt")
.with_title("My File")
.with_description("A file resource")
.with_mime_type("text/plain"),
));
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "resource_link");
assert_eq!(json["name"], "my-file");
assert_eq!(json["uri"], "file:///path/to/file.txt");
assert_eq!(json["title"], "My File");
let roundtrip: Content = serde_json::from_value(json).unwrap();
match roundtrip {
Content::ResourceLink(rl) => {
assert_eq!(rl.name, "my-file");
assert_eq!(rl.uri, "file:///path/to/file.txt");
},
_ => panic!("Expected Content::ResourceLink"),
}
}
#[test]
fn test_annotations_default() {
let ann = Annotations::new();
assert!(ann.audience.is_none());
assert!(ann.priority.is_none());
assert!(ann.last_modified.is_none());
}
#[test]
fn test_content_text_helper() {
let c = Content::text("Hello");
match c {
Content::Text { text } => assert_eq!(text, "Hello"),
_ => panic!("Expected Content::Text"),
}
}
#[test]
fn test_content_image_helper() {
let c = Content::image("data==", "image/png");
match c {
Content::Image { data, mime_type } => {
assert_eq!(data, "data==");
assert_eq!(mime_type, "image/png");
},
_ => panic!("Expected Content::Image"),
}
}
#[test]
fn test_content_resource_neither_branch_emits_an_empty_text_arm() {
let c = Content::Resource {
uri: "file://test.txt".to_string(),
text: None,
blob: None,
mime_type: None,
annotations: None,
meta: None,
};
match &c {
Content::Resource {
uri,
text,
blob,
mime_type,
annotations,
meta,
} => {
assert_eq!(uri, "file://test.txt");
assert!(text.is_none());
assert!(blob.is_none());
assert!(mime_type.is_none());
assert!(annotations.is_none());
assert!(meta.is_none());
},
_ => panic!("Expected Content::Resource"),
}
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"file://test.txt","text":""}}"#,
"the NEITHER branch must emit a valid TextResourceContents, never an \
object carrying neither `text` nor `blob`"
);
}
#[test]
fn test_content_audio_helper() {
let c = Content::audio("audiodata==", "audio/wav");
match c {
Content::Audio {
data,
mime_type,
annotations,
meta,
} => {
assert_eq!(data, "audiodata==");
assert_eq!(mime_type, "audio/wav");
assert!(annotations.is_none());
assert!(meta.is_none());
},
_ => panic!("Expected Content::Audio"),
}
}
#[test]
fn test_content_resource_link_helper() {
let c = Content::resource_link("my-file", "file:///path");
match c {
Content::ResourceLink(rl) => {
assert_eq!(rl.name, "my-file");
assert_eq!(rl.uri, "file:///path");
assert!(rl.title.is_none());
},
_ => panic!("Expected Content::ResourceLink"),
}
}
#[test]
fn test_annotations_with_methods() {
let ann = Annotations::new()
.with_priority(0.9)
.with_audience(vec!["user".into(), "admin".into()])
.with_last_modified("2025-01-01T00:00:00Z");
assert_eq!(ann.priority, Some(0.9));
assert_eq!(ann.audience.as_ref().unwrap().len(), 2);
assert_eq!(ann.last_modified.as_deref(), Some("2025-01-01T00:00:00Z"));
}
#[test]
fn test_embedded_resource_emits_the_nested_spec_shape() {
let c = Content::resource_with_text("emb://one.txt", "body", "text/plain");
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"emb://one.txt","mimeType":"text/plain","text":"body"}}"#
);
}
#[test]
fn test_embedded_resource_emits_the_blob_arm() {
let c = Content::resource_with_blob("emb://pixel.png", "iVBORw0KGgo=", "image/png");
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"emb://pixel.png","mimeType":"image/png","blob":"iVBORw0KGgo="}}"#
);
}
#[test]
fn test_embedded_resource_annotations_sit_outside_the_resource_object() {
let c = Content::resource_with_text("emb://ann.txt", "body", "text/plain")
.with_annotations(
Annotations::new()
.with_audience(vec!["user".to_string()])
.with_priority(0.5),
);
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"emb://ann.txt","mimeType":"text/plain","text":"body"},"annotations":{"audience":["user"],"priority":0.5}}"#
);
}
#[test]
fn test_embedded_resource_meta_is_content_level_and_last() {
let mut meta = serde_json::Map::new();
meta.insert("k".to_string(), Value::String("v".to_string()));
let c = Content::Resource {
uri: "emb://m.txt".to_string(),
text: Some("body".to_string()),
blob: None,
mime_type: None,
annotations: None,
meta: Some(meta),
};
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"emb://m.txt","text":"body"},"_meta":{"k":"v"}}"#
);
}
#[test]
fn test_tolerant_reader_accepts_the_nested_spec_shape() {
let json = json!({
"type": "resource",
"resource": { "uri": "emb://n.txt", "mimeType": "text/plain", "text": "body" }
});
let c: Content = serde_json::from_value(json).unwrap();
match &c {
Content::Resource {
uri,
text,
mime_type,
..
} => {
assert_eq!(uri, "emb://n.txt");
assert_eq!(text.as_deref(), Some("body"));
assert_eq!(mime_type.as_deref(), Some("text/plain"));
},
other => panic!("expected Content::Resource, got {other:?}"),
}
}
#[test]
fn test_tolerant_reader_accepts_a_nested_blob() {
let json = json!({
"type": "resource",
"resource": { "uri": "emb://p.png", "mimeType": "image/png", "blob": "AAA=" }
});
let c: Content = serde_json::from_value(json).unwrap();
match &c {
Content::Resource { blob, text, .. } => {
assert_eq!(blob.as_deref(), Some("AAA="));
assert!(text.is_none());
},
other => panic!("expected Content::Resource, got {other:?}"),
}
}
#[test]
fn test_tolerant_reader_accepts_the_legacy_flat_shape_and_re_emits_nested() {
let json = json!({
"type": "resource",
"uri": "emb://f.txt",
"text": "body",
"mimeType": "text/plain"
});
let c: Content = serde_json::from_value(json).unwrap();
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"emb://f.txt","mimeType":"text/plain","text":"body"}}"#
);
}
#[test]
fn test_both_accepted_shapes_converge_on_identical_bytes() {
let flat: Content = serde_json::from_value(json!({
"type": "resource", "uri": "u", "mimeType": "text/plain", "text": "t"
}))
.unwrap();
let nested: Content = serde_json::from_value(json!({
"type": "resource",
"resource": { "uri": "u", "mimeType": "text/plain", "text": "t" }
}))
.unwrap();
assert_eq!(
serde_json::to_string(&flat).unwrap(),
serde_json::to_string(&nested).unwrap()
);
}
#[test]
fn test_tolerant_reader_rejects_both_text_and_blob() {
let nested = serde_json::from_value::<Content>(json!({
"type": "resource",
"resource": { "uri": "u", "text": "t", "blob": "AAA=" }
}));
let error = nested.expect_err("a text+blob payload violates the spec XOR");
assert!(
error.to_string().contains("XOR"),
"the rejection must name the contract it enforces, got: {error}"
);
let flat = serde_json::from_value::<Content>(json!({
"type": "resource", "uri": "u", "text": "t", "blob": "AAA="
}));
assert!(
flat.is_err(),
"the legacy flat shape is held to the same XOR"
);
}
#[test]
fn test_emitter_resolves_text_and_blob_deterministically_to_text() {
let c = Content::Resource {
uri: "u".to_string(),
text: Some("t".to_string()),
blob: Some("AAA=".to_string()),
mime_type: None,
annotations: None,
meta: None,
};
assert_eq!(
serde_json::to_string(&c).unwrap(),
r#"{"type":"resource","resource":{"uri":"u","text":"t"}}"#
);
}
#[test]
fn test_tolerant_reader_rejects_a_payload_with_no_uri_at_all() {
let error = serde_json::from_value::<Content>(json!({ "type": "resource" }))
.expect_err("an embedded resource needs a uri somewhere");
assert!(
error.to_string().contains("missing field `uri`"),
"got: {error}"
);
}
#[test]
fn test_non_resource_arms_are_byte_identical_to_the_previous_derive() {
assert_eq!(
serde_json::to_string(&Content::text("hi")).unwrap(),
r#"{"type":"text","text":"hi"}"#
);
assert_eq!(
serde_json::to_string(&Content::image("AAA=", "image/png")).unwrap(),
r#"{"type":"image","data":"AAA=","mimeType":"image/png"}"#
);
assert_eq!(
serde_json::to_string(&Content::audio("BBB=", "audio/wav")).unwrap(),
r#"{"type":"audio","data":"BBB=","mimeType":"audio/wav"}"#
);
assert_eq!(
serde_json::to_string(&Content::resource_link("n", "file:///p")).unwrap(),
r#"{"type":"resource_link","name":"n","uri":"file:///p"}"#
);
}
#[test]
fn test_read_resource_contents_projection_stays_flat_and_gains_blob() {
#[derive(Serialize, Deserialize)]
struct Wrapper {
#[serde(with = "super::resource_contents_serde")]
contents: Vec<Content>,
}
let wrapper = Wrapper {
contents: vec![
Content::resource_with_text("res://a.txt", "body", "text/plain"),
Content::resource_with_blob("res://p.png", "iVBORw0KGgo=", "image/png"),
],
};
assert_eq!(
serde_json::to_string(&wrapper).unwrap(),
r#"{"contents":[{"uri":"res://a.txt","mimeType":"text/plain","text":"body"},{"uri":"res://p.png","mimeType":"image/png","blob":"iVBORw0KGgo="}]}"#
);
let back: Wrapper = serde_json::from_str(
r#"{"contents":[{"uri":"res://p.png","mimeType":"image/png","blob":"iVBORw0KGgo="}]}"#,
)
.unwrap();
match &back.contents[0] {
Content::Resource { blob, .. } => assert_eq!(blob.as_deref(), Some("iVBORw0KGgo=")),
other => panic!("expected Content::Resource, got {other:?}"),
}
}
#[test]
fn test_resource_link_content_with_methods() {
let rl = ResourceLinkContent::new("test", "file:///test")
.with_title("Test")
.with_description("A test resource")
.with_mime_type("text/plain");
assert_eq!(rl.name, "test");
assert_eq!(rl.uri, "file:///test");
assert_eq!(rl.title.as_deref(), Some("Test"));
assert_eq!(rl.description.as_deref(), Some("A test resource"));
assert_eq!(rl.mime_type.as_deref(), Some("text/plain"));
}
}
#[cfg(test)]
mod content_properties {
use super::Content;
use serde_json::json;
proptest::proptest! {
#[test]
fn property_nested_embedded_resource_is_a_serialization_fixed_point(
uri in ".{0,64}",
mime in ".{0,32}",
text in ".{0,128}",
) {
let nested = json!({
"type": "resource",
"resource": { "uri": uri, "mimeType": mime, "text": text }
});
let parsed: Content = serde_json::from_value(nested.clone())
.expect("the spec shape must always parse");
let once = serde_json::to_value(&parsed).expect("Content always serializes");
proptest::prop_assert_eq!(
&once,
&nested,
"nested in must produce nested out, unchanged"
);
let reread: Content = serde_json::from_value(once.clone())
.expect("pmcp must be able to read its own output back");
let twice = serde_json::to_value(&reread).expect("Content always serializes");
proptest::prop_assert_eq!(once, twice, "serialization must be a fixed point");
}
#[test]
fn property_flat_embedded_resource_is_accepted_and_re_emitted_nested(
uri in ".{0,64}",
mime in ".{0,32}",
text in ".{0,128}",
) {
let flat = json!({
"type": "resource", "uri": uri, "mimeType": mime, "text": text
});
let nested = json!({
"type": "resource",
"resource": { "uri": uri, "mimeType": mime, "text": text }
});
let from_flat: Content = serde_json::from_value(flat.clone())
.expect("the legacy flat shape must keep parsing");
let emitted = serde_json::to_value(&from_flat).expect("Content always serializes");
proptest::prop_assert!(
emitted.get("uri").is_none(),
"a flat input must not be re-emitted flat, got {}",
emitted
);
proptest::prop_assert_eq!(
emitted,
nested,
"the two accepted shapes must converge on identical bytes"
);
}
}
}