turul_mcp_protocol_2025_06_18/
content.rs

1//! Content types for MCP 2025-06-18 specification
2//!
3//! This module contains the exact content type definitions from the MCP spec,
4//! ensuring perfect compliance with the TypeScript schema definitions.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10use crate::meta::Annotations;
11
12/// Text resource contents (matches TypeScript TextResourceContents exactly)
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct TextResourceContents {
16    /// The URI of this resource (REQUIRED by MCP spec)
17    pub uri: String,
18    /// The MIME type of this resource, if known
19    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
20    pub mime_type: Option<String>,
21    /// Meta information (REQUIRED by MCP spec)
22    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
23    pub meta: Option<HashMap<String, Value>>,
24    /// The text content
25    pub text: String,
26}
27
28/// Binary resource contents (matches TypeScript BlobResourceContents exactly)
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct BlobResourceContents {
32    /// The URI of this resource (REQUIRED by MCP spec)
33    pub uri: String,
34    /// The MIME type of this resource, if known
35    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
36    pub mime_type: Option<String>,
37    /// Meta information (REQUIRED by MCP spec)
38    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
39    pub meta: Option<HashMap<String, Value>>,
40    /// Base64-encoded binary data
41    pub blob: String,
42}
43
44/// Resource contents union type (matches TypeScript TextResourceContents | BlobResourceContents)
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(untagged)]
47pub enum ResourceContents {
48    /// Text content
49    Text(TextResourceContents),
50    /// Binary content
51    Blob(BlobResourceContents),
52}
53
54/// Resource reference for resource links (matches TypeScript Resource interface)
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ResourceReference {
58    /// The URI of this resource
59    pub uri: String,
60    /// A human-readable name for this resource
61    pub name: String,
62    /// A human-readable title for this resource
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub title: Option<String>,
65    /// A description of what this resource represents or contains
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    /// The MIME type of this resource, if known
69    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
70    pub mime_type: Option<String>,
71    /// Client annotations for this resource
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub annotations: Option<Annotations>,
74    /// Additional metadata for this resource
75    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
76    pub meta: Option<HashMap<String, Value>>,
77}
78
79/// Content block union type matching MCP 2025-06-18 specification exactly
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "type")]
82pub enum ContentBlock {
83    /// Text content
84    #[serde(rename = "text")]
85    Text {
86        text: String,
87        #[serde(skip_serializing_if = "Option::is_none")]
88        annotations: Option<Annotations>,
89        #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
90        meta: Option<HashMap<String, Value>>,
91    },
92    /// Image content
93    #[serde(rename = "image")]
94    Image {
95        /// Base64-encoded image data
96        data: String,
97        /// MIME type of the image
98        #[serde(rename = "mimeType")]
99        mime_type: String,
100        #[serde(skip_serializing_if = "Option::is_none")]
101        annotations: Option<Annotations>,
102        #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
103        meta: Option<HashMap<String, Value>>,
104    },
105    /// Audio content
106    #[serde(rename = "audio")]
107    Audio {
108        /// Base64-encoded audio data
109        data: String,
110        /// MIME type of the audio
111        #[serde(rename = "mimeType")]
112        mime_type: String,
113        #[serde(skip_serializing_if = "Option::is_none")]
114        annotations: Option<Annotations>,
115        #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
116        meta: Option<HashMap<String, Value>>,
117    },
118    /// Resource link (ResourceLink from MCP spec)
119    #[serde(rename = "resource_link")]
120    ResourceLink {
121        #[serde(flatten)]
122        resource: ResourceReference,
123        #[serde(skip_serializing_if = "Option::is_none")]
124        annotations: Option<Annotations>,
125        #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
126        meta: Option<HashMap<String, Value>>,
127    },
128    /// Embedded resource (EmbeddedResource from MCP spec)
129    #[serde(rename = "resource")]
130    Resource {
131        resource: ResourceContents,
132        #[serde(skip_serializing_if = "Option::is_none")]
133        annotations: Option<Annotations>,
134        #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
135        meta: Option<HashMap<String, Value>>,
136    },
137}
138
139impl ContentBlock {
140    /// Create text content
141    pub fn text(text: impl Into<String>) -> Self {
142        Self::Text {
143            text: text.into(),
144            annotations: None,
145            meta: None,
146        }
147    }
148
149    /// Create text content with annotations
150    pub fn text_with_annotations(text: impl Into<String>, annotations: Annotations) -> Self {
151        Self::Text {
152            text: text.into(),
153            annotations: Some(annotations),
154            meta: None,
155        }
156    }
157
158    /// Create image content
159    pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
160        Self::Image {
161            data: data.into(),
162            mime_type: mime_type.into(),
163            annotations: None,
164            meta: None,
165        }
166    }
167
168    /// Create audio content
169    pub fn audio(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
170        Self::Audio {
171            data: data.into(),
172            mime_type: mime_type.into(),
173            annotations: None,
174            meta: None,
175        }
176    }
177
178    /// Create resource link
179    pub fn resource_link(resource: ResourceReference) -> Self {
180        Self::ResourceLink {
181            resource,
182            annotations: None,
183            meta: None,
184        }
185    }
186
187    /// Create embedded resource
188    pub fn resource(resource: ResourceContents) -> Self {
189        Self::Resource {
190            resource,
191            annotations: None,
192            meta: None,
193        }
194    }
195
196    /// Add annotations to any content block
197    pub fn with_annotations(mut self, annotations: Annotations) -> Self {
198        match &mut self {
199            ContentBlock::Text { annotations: a, .. }
200            | ContentBlock::Image { annotations: a, .. }
201            | ContentBlock::Audio { annotations: a, .. }
202            | ContentBlock::ResourceLink { annotations: a, .. }
203            | ContentBlock::Resource { annotations: a, .. } => {
204                *a = Some(annotations);
205            }
206        }
207        self
208    }
209
210    /// Add meta to any content block
211    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
212        match &mut self {
213            ContentBlock::Text { meta: m, .. }
214            | ContentBlock::Image { meta: m, .. }
215            | ContentBlock::Audio { meta: m, .. }
216            | ContentBlock::ResourceLink { meta: m, .. }
217            | ContentBlock::Resource { meta: m, .. } => {
218                *m = Some(meta);
219            }
220        }
221        self
222    }
223}
224
225impl ResourceContents {
226    /// Create text resource contents with required URI
227    pub fn text(uri: impl Into<String>, text: impl Into<String>) -> Self {
228        Self::Text(TextResourceContents {
229            uri: uri.into(),
230            mime_type: None,
231            meta: None,
232            text: text.into(),
233        })
234    }
235
236    /// Create text resource contents with MIME type
237    pub fn text_with_mime(
238        uri: impl Into<String>,
239        text: impl Into<String>,
240        mime_type: impl Into<String>,
241    ) -> Self {
242        Self::Text(TextResourceContents {
243            uri: uri.into(),
244            mime_type: Some(mime_type.into()),
245            meta: None,
246            text: text.into(),
247        })
248    }
249
250    /// Create blob resource contents with required URI
251    pub fn blob(
252        uri: impl Into<String>,
253        blob: impl Into<String>,
254        mime_type: impl Into<String>,
255    ) -> Self {
256        Self::Blob(BlobResourceContents {
257            uri: uri.into(),
258            mime_type: Some(mime_type.into()),
259            meta: None,
260            blob: blob.into(),
261        })
262    }
263}
264
265impl ResourceReference {
266    /// Create resource reference
267    pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
268        Self {
269            uri: uri.into(),
270            name: name.into(),
271            title: None,
272            description: None,
273            mime_type: None,
274            annotations: None,
275            meta: None,
276        }
277    }
278
279    /// Add title
280    pub fn with_title(mut self, title: impl Into<String>) -> Self {
281        self.title = Some(title.into());
282        self
283    }
284
285    /// Add description
286    pub fn with_description(mut self, description: impl Into<String>) -> Self {
287        self.description = Some(description.into());
288        self
289    }
290
291    /// Add MIME type
292    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
293        self.mime_type = Some(mime_type.into());
294        self
295    }
296
297    /// Add annotations
298    pub fn with_annotations(mut self, annotations: Annotations) -> Self {
299        self.annotations = Some(annotations);
300        self
301    }
302
303    /// Add meta information
304    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
305        self.meta = Some(meta);
306        self
307    }
308}
309
310impl TextResourceContents {
311    /// Create new text resource contents
312    pub fn new(uri: impl Into<String>, text: impl Into<String>) -> Self {
313        Self {
314            uri: uri.into(),
315            mime_type: None,
316            meta: None,
317            text: text.into(),
318        }
319    }
320
321    /// Add MIME type
322    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
323        self.mime_type = Some(mime_type.into());
324        self
325    }
326
327    /// Add meta information
328    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
329        self.meta = Some(meta);
330        self
331    }
332}
333
334impl BlobResourceContents {
335    /// Create new blob resource contents
336    pub fn new(
337        uri: impl Into<String>,
338        blob: impl Into<String>,
339        mime_type: impl Into<String>,
340    ) -> Self {
341        Self {
342            uri: uri.into(),
343            mime_type: Some(mime_type.into()),
344            meta: None,
345            blob: blob.into(),
346        }
347    }
348
349    /// Add meta information
350    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
351        self.meta = Some(meta);
352        self
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use serde_json::json;
360
361    #[test]
362    fn test_resource_reference_serialization_with_annotations_and_meta() {
363        let mut meta = HashMap::new();
364        meta.insert("version".to_string(), json!("1.0"));
365        meta.insert("created_by".to_string(), json!("test"));
366
367        let resource_ref = ResourceReference::new("file:///test/data.json", "test_data")
368            .with_title("Test Data")
369            .with_description("Sample data for testing")
370            .with_mime_type("application/json")
371            .with_annotations(Annotations::new().with_title("Test Resource"))
372            .with_meta(meta);
373
374        let resource_link = ContentBlock::resource_link(resource_ref);
375
376        // Test serialization round-trip
377        let json_str = serde_json::to_string(&resource_link).unwrap();
378        let deserialized: ContentBlock = serde_json::from_str(&json_str).unwrap();
379
380        // Verify structure - with #[serde(flatten)], ResourceReference fields get flattened
381        if let ContentBlock::ResourceLink {
382            resource,
383            annotations,
384            meta,
385        } = deserialized
386        {
387            assert_eq!(resource.uri, "file:///test/data.json");
388            assert_eq!(resource.name, "test_data");
389            assert_eq!(resource.title, Some("Test Data".to_string()));
390            assert_eq!(
391                resource.description,
392                Some("Sample data for testing".to_string())
393            );
394            assert_eq!(resource.mime_type, Some("application/json".to_string()));
395
396            // With #[serde(flatten)], the ResourceReference annotations and meta get flattened
397            // during serialization, but during deserialization, serde routes them to the
398            // ContentBlock level since both structs have these fields.
399
400            // ResourceReference level should be None after deserialization
401            assert!(resource.annotations.is_none());
402            assert!(resource.meta.is_none());
403
404            // ContentBlock level should contain the annotations and meta
405            assert!(annotations.is_some());
406            assert_eq!(
407                annotations.unwrap().title,
408                Some("Test Resource".to_string())
409            );
410
411            assert!(meta.is_some());
412            let cb_meta = meta.unwrap();
413            assert_eq!(cb_meta.get("version"), Some(&json!("1.0")));
414            assert_eq!(cb_meta.get("created_by"), Some(&json!("test")));
415        } else {
416            panic!("Expected ResourceLink variant");
417        }
418    }
419
420    #[test]
421    fn test_resource_reference_minimal() {
422        let resource_ref = ResourceReference::new("file:///minimal.txt", "minimal");
423        let resource_link = ContentBlock::resource_link(resource_ref);
424
425        let json_str = serde_json::to_string(&resource_link).unwrap();
426        let deserialized: ContentBlock = serde_json::from_str(&json_str).unwrap();
427
428        if let ContentBlock::ResourceLink { resource, .. } = deserialized {
429            assert_eq!(resource.uri, "file:///minimal.txt");
430            assert_eq!(resource.name, "minimal");
431            assert!(resource.title.is_none());
432            assert!(resource.description.is_none());
433            assert!(resource.mime_type.is_none());
434            assert!(resource.annotations.is_none());
435            assert!(resource.meta.is_none());
436        } else {
437            panic!("Expected ResourceLink variant");
438        }
439    }
440}