Skip to main content

rmcp/model/
content.rs

1//! Content sent around agents, extensions, and LLMs
2//! The various content types can be display to humans but also understood by models
3//! They include optional annotations used to help inform agent usage
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6
7use super::{AnnotateAble, Annotated, resource::ResourceContents};
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
12#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
13pub struct RawTextContent {
14    pub text: String,
15    /// Optional protocol-level metadata for this content block
16    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
17    pub meta: Option<super::Meta>,
18}
19pub type TextContent = Annotated<RawTextContent>;
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
24pub struct RawImageContent {
25    /// The base64-encoded image
26    pub data: String,
27    pub mime_type: String,
28    /// Optional protocol-level metadata for this content block
29    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
30    pub meta: Option<super::Meta>,
31}
32
33pub type ImageContent = Annotated<RawImageContent>;
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
37#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
38pub struct RawEmbeddedResource {
39    /// Optional protocol-level metadata for this content block
40    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
41    pub meta: Option<super::Meta>,
42    pub resource: ResourceContents,
43}
44
45impl RawEmbeddedResource {
46    /// Create a new RawEmbeddedResource.
47    pub fn new(resource: ResourceContents) -> Self {
48        Self {
49            meta: None,
50            resource,
51        }
52    }
53}
54
55pub type EmbeddedResource = Annotated<RawEmbeddedResource>;
56
57impl EmbeddedResource {
58    pub fn get_text(&self) -> String {
59        match &self.resource {
60            ResourceContents::TextResourceContents { text, .. } => text.clone(),
61            _ => String::new(),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
69#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
70pub struct RawAudioContent {
71    pub data: String,
72    pub mime_type: String,
73}
74
75pub type AudioContent = Annotated<RawAudioContent>;
76
77/// Tool call request from assistant (SEP-1577).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
81#[non_exhaustive]
82pub struct ToolUseContent {
83    /// Unique identifier for this tool call
84    pub id: String,
85    /// Name of the tool to call
86    pub name: String,
87    /// Input arguments for the tool
88    pub input: super::JsonObject,
89    /// Optional metadata (preserved for caching)
90    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
91    pub meta: Option<super::Meta>,
92}
93
94/// Tool execution result in user message (SEP-1577).
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
98#[non_exhaustive]
99pub struct ToolResultContent {
100    /// Optional metadata
101    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
102    pub meta: Option<super::Meta>,
103    /// ID of the corresponding tool use
104    pub tool_use_id: String,
105    /// Content blocks returned by the tool
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub content: Vec<Content>,
108    /// Optional structured result
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub structured_content: Option<super::JsonObject>,
111    /// Whether tool execution failed
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub is_error: Option<bool>,
114}
115
116impl ToolUseContent {
117    pub fn new(id: impl Into<String>, name: impl Into<String>, input: super::JsonObject) -> Self {
118        Self {
119            id: id.into(),
120            name: name.into(),
121            input,
122            meta: None,
123        }
124    }
125}
126
127impl ToolResultContent {
128    pub fn new(tool_use_id: impl Into<String>, content: Vec<Content>) -> Self {
129        Self {
130            meta: None,
131            tool_use_id: tool_use_id.into(),
132            content,
133            structured_content: None,
134            is_error: None,
135        }
136    }
137
138    pub fn error(tool_use_id: impl Into<String>, content: Vec<Content>) -> Self {
139        Self {
140            meta: None,
141            tool_use_id: tool_use_id.into(),
142            content,
143            structured_content: None,
144            is_error: Some(true),
145        }
146    }
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(tag = "type", rename_all = "snake_case")]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
153pub enum RawContent {
154    Text(RawTextContent),
155    Image(RawImageContent),
156    Resource(RawEmbeddedResource),
157    Audio(RawAudioContent),
158    ResourceLink(super::resource::RawResource),
159}
160
161pub type Content = Annotated<RawContent>;
162
163impl RawContent {
164    pub fn json<S: Serialize>(json: S) -> Result<Self, crate::ErrorData> {
165        let json = serde_json::to_string(&json).map_err(|e| {
166            crate::ErrorData::internal_error(
167                "fail to serialize response to json",
168                Some(json!(
169                    {"reason": e.to_string()}
170                )),
171            )
172        })?;
173        Ok(RawContent::text(json))
174    }
175
176    pub fn text<S: Into<String>>(text: S) -> Self {
177        RawContent::Text(RawTextContent {
178            text: text.into(),
179            meta: None,
180        })
181    }
182
183    pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
184        RawContent::Image(RawImageContent {
185            data: data.into(),
186            mime_type: mime_type.into(),
187            meta: None,
188        })
189    }
190
191    pub fn resource(resource: ResourceContents) -> Self {
192        RawContent::Resource(RawEmbeddedResource {
193            meta: None,
194            resource,
195        })
196    }
197
198    pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
199        RawContent::Resource(RawEmbeddedResource {
200            meta: None,
201            resource: ResourceContents::TextResourceContents {
202                uri: uri.into(),
203                mime_type: Some("text".to_string()),
204                text: content.into(),
205                meta: None,
206            },
207        })
208    }
209
210    /// Get the text content if this is a TextContent variant
211    pub fn as_text(&self) -> Option<&RawTextContent> {
212        match self {
213            RawContent::Text(text) => Some(text),
214            _ => None,
215        }
216    }
217
218    /// Get the image content if this is an ImageContent variant
219    pub fn as_image(&self) -> Option<&RawImageContent> {
220        match self {
221            RawContent::Image(image) => Some(image),
222            _ => None,
223        }
224    }
225
226    /// Get the resource content if this is an ImageContent variant
227    pub fn as_resource(&self) -> Option<&RawEmbeddedResource> {
228        match self {
229            RawContent::Resource(resource) => Some(resource),
230            _ => None,
231        }
232    }
233
234    /// Get the resource link if this is a ResourceLink variant
235    pub fn as_resource_link(&self) -> Option<&super::resource::RawResource> {
236        match self {
237            RawContent::ResourceLink(link) => Some(link),
238            _ => None,
239        }
240    }
241
242    /// Create a resource link content
243    pub fn resource_link(resource: super::resource::RawResource) -> Self {
244        RawContent::ResourceLink(resource)
245    }
246}
247
248impl Content {
249    pub fn text<S: Into<String>>(text: S) -> Self {
250        RawContent::text(text).no_annotation()
251    }
252
253    pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
254        RawContent::image(data, mime_type).no_annotation()
255    }
256
257    pub fn resource(resource: ResourceContents) -> Self {
258        RawContent::resource(resource).no_annotation()
259    }
260
261    pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
262        RawContent::embedded_text(uri, content).no_annotation()
263    }
264
265    pub fn json<S: Serialize>(json: S) -> Result<Self, crate::ErrorData> {
266        RawContent::json(json).map(|c| c.no_annotation())
267    }
268
269    /// Create a resource link content
270    pub fn resource_link(resource: super::resource::RawResource) -> Self {
271        RawContent::resource_link(resource).no_annotation()
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct JsonContent<S: Serialize>(S);
277/// Types that can be converted into a list of contents
278pub trait IntoContents {
279    fn into_contents(self) -> Vec<Content>;
280}
281
282impl IntoContents for Content {
283    fn into_contents(self) -> Vec<Content> {
284        vec![self]
285    }
286}
287
288impl IntoContents for String {
289    fn into_contents(self) -> Vec<Content> {
290        vec![Content::text(self)]
291    }
292}
293
294impl IntoContents for () {
295    fn into_contents(self) -> Vec<Content> {
296        vec![]
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use serde_json;
303
304    use super::*;
305
306    #[test]
307    fn test_image_content_serialization() {
308        let image_content = RawImageContent {
309            data: "base64data".to_string(),
310            mime_type: "image/png".to_string(),
311            meta: None,
312        };
313
314        let json = serde_json::to_string(&image_content).unwrap();
315        println!("ImageContent JSON: {}", json);
316
317        // Verify it contains mimeType (camelCase) not mime_type (snake_case)
318        assert!(json.contains("mimeType"));
319        assert!(!json.contains("mime_type"));
320    }
321
322    #[test]
323    fn test_audio_content_serialization() {
324        let audio_content = RawAudioContent {
325            data: "base64audiodata".to_string(),
326            mime_type: "audio/wav".to_string(),
327        };
328
329        let json = serde_json::to_string(&audio_content).unwrap();
330        println!("AudioContent JSON: {}", json);
331
332        // Verify it contains mimeType (camelCase) not mime_type (snake_case)
333        assert!(json.contains("mimeType"));
334        assert!(!json.contains("mime_type"));
335    }
336
337    #[test]
338    fn test_resource_link_serialization() {
339        use super::super::resource::RawResource;
340
341        let resource_link = RawContent::ResourceLink(RawResource {
342            uri: "file:///test.txt".to_string(),
343            name: "test.txt".to_string(),
344            title: None,
345            description: Some("A test file".to_string()),
346            mime_type: Some("text/plain".to_string()),
347            size: Some(100),
348            icons: None,
349            meta: None,
350        });
351
352        let json = serde_json::to_string(&resource_link).unwrap();
353        println!("ResourceLink JSON: {}", json);
354
355        // Verify it contains the correct type tag
356        assert!(json.contains("\"type\":\"resource_link\""));
357        assert!(json.contains("\"uri\":\"file:///test.txt\""));
358        assert!(json.contains("\"name\":\"test.txt\""));
359    }
360
361    #[test]
362    fn test_resource_link_deserialization() {
363        let json = r#"{
364            "type": "resource_link",
365            "uri": "file:///example.txt",
366            "name": "example.txt",
367            "description": "Example file",
368            "mimeType": "text/plain"
369        }"#;
370
371        let content: RawContent = serde_json::from_str(json).unwrap();
372
373        if let RawContent::ResourceLink(resource) = content {
374            assert_eq!(resource.uri, "file:///example.txt");
375            assert_eq!(resource.name, "example.txt");
376            assert_eq!(resource.description, Some("Example file".to_string()));
377            assert_eq!(resource.mime_type, Some("text/plain".to_string()));
378        } else {
379            panic!("Expected ResourceLink variant");
380        }
381    }
382}