Skip to main content

turbomcp_types/
content.rs

1//! Content types for MCP messages (MCP 2025-11-25).
2//!
3//! This module defines the content type unions used in the MCP protocol:
4//!
5//! - [`Content`] (`ContentBlock`): Used in tool call results and prompt messages.
6//!   Variants: Text, Image, Audio, ResourceLink, Resource (EmbeddedResource).
7//!
8//! - [`SamplingContent`] (`SamplingMessageContentBlock`): Used in sampling messages.
9//!   Variants: Text, Image, Audio, ToolUse, ToolResult.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[cfg(not(feature = "std"))]
15use alloc::{collections::BTreeMap as HashMap, string::String, vec, vec::Vec};
16#[cfg(feature = "std")]
17use std::collections::HashMap;
18
19/// Role in a conversation or prompt message.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Role {
23    /// User role (human or client)
24    #[default]
25    User,
26    /// Assistant role (AI or server)
27    Assistant,
28}
29
30impl core::fmt::Display for Role {
31    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
32        match self {
33            Self::User => f.write_str("user"),
34            Self::Assistant => f.write_str("assistant"),
35        }
36    }
37}
38
39// =============================================================================
40// ContentBlock — used in tool results and prompt messages
41// =============================================================================
42
43/// Content block in MCP messages (`ContentBlock` per spec).
44///
45/// Used in `CallToolResult.content` and `PromptMessage.content`.
46///
47/// Per MCP 2025-11-25, the union is:
48/// `TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource`
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50#[serde(tag = "type")]
51pub enum Content {
52    /// Text content
53    #[serde(rename = "text")]
54    Text(TextContent),
55    /// Image content (base64 encoded)
56    #[serde(rename = "image")]
57    Image(ImageContent),
58    /// Audio content (base64 encoded)
59    #[serde(rename = "audio")]
60    Audio(AudioContent),
61    /// Resource link (reference to a resource without embedding)
62    #[serde(rename = "resource_link")]
63    ResourceLink(ResourceLink),
64    /// Embedded resource content
65    #[serde(rename = "resource")]
66    Resource(EmbeddedResource),
67}
68
69impl Default for Content {
70    fn default() -> Self {
71        Self::text("")
72    }
73}
74
75impl Content {
76    /// Create text content.
77    #[must_use]
78    pub fn text(text: impl Into<String>) -> Self {
79        Self::Text(TextContent {
80            text: text.into(),
81            annotations: None,
82            meta: None,
83        })
84    }
85
86    /// Create image content from base64 data.
87    #[must_use]
88    pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
89        Self::Image(ImageContent {
90            data: data.into(),
91            mime_type: mime_type.into(),
92            annotations: None,
93            meta: None,
94        })
95    }
96
97    /// Create audio content from base64 data.
98    #[must_use]
99    pub fn audio(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
100        Self::Audio(AudioContent {
101            data: data.into(),
102            mime_type: mime_type.into(),
103            annotations: None,
104            meta: None,
105        })
106    }
107
108    /// Create a resource link.
109    #[must_use]
110    pub fn resource_link(resource: crate::definitions::Resource) -> Self {
111        Self::ResourceLink(ResourceLink {
112            uri: resource.uri,
113            name: resource.name,
114            description: resource.description,
115            title: resource.title,
116            icons: resource.icons,
117            mime_type: resource.mime_type,
118            annotations: resource.annotations,
119            size: resource.size,
120            meta: resource.meta,
121        })
122    }
123
124    /// Create embedded resource content.
125    #[must_use]
126    pub fn resource(uri: impl Into<String>, text: impl Into<String>) -> Self {
127        Self::Resource(EmbeddedResource {
128            resource: ResourceContents::Text(TextResourceContents {
129                uri: uri.into(),
130                mime_type: Some("text/plain".into()),
131                text: text.into(),
132                meta: None,
133            }),
134            annotations: None,
135            meta: None,
136        })
137    }
138
139    /// Check if this is text content.
140    #[must_use]
141    pub fn is_text(&self) -> bool {
142        matches!(self, Self::Text(_))
143    }
144
145    /// Get the text if this is text content.
146    #[must_use]
147    pub fn as_text(&self) -> Option<&str> {
148        match self {
149            Self::Text(t) => Some(&t.text),
150            _ => None,
151        }
152    }
153
154    /// Check if this is image content.
155    #[must_use]
156    pub fn is_image(&self) -> bool {
157        matches!(self, Self::Image(_))
158    }
159
160    /// Check if this is audio content.
161    #[must_use]
162    pub fn is_audio(&self) -> bool {
163        matches!(self, Self::Audio(_))
164    }
165
166    /// Check if this is a resource link.
167    #[must_use]
168    pub fn is_resource_link(&self) -> bool {
169        matches!(self, Self::ResourceLink(_))
170    }
171
172    /// Check if this is resource content.
173    #[must_use]
174    pub fn is_resource(&self) -> bool {
175        matches!(self, Self::Resource(_))
176    }
177
178    /// Add annotations to this content.
179    #[must_use]
180    pub fn with_annotations(mut self, annotations: Annotations) -> Self {
181        match &mut self {
182            Self::Text(t) => t.annotations = Some(annotations),
183            Self::Image(i) => i.annotations = Some(annotations),
184            Self::Audio(a) => a.annotations = Some(annotations),
185            Self::ResourceLink(r) => {
186                r.annotations = Some(crate::definitions::ResourceAnnotations {
187                    audience: annotations.audience,
188                    priority: annotations.priority,
189                    last_modified: annotations.last_modified,
190                })
191            }
192            Self::Resource(r) => r.annotations = Some(annotations),
193        }
194        self
195    }
196}
197
198// =============================================================================
199// SamplingMessageContentBlock — used in sampling messages
200// =============================================================================
201
202/// Content block for sampling messages (`SamplingMessageContentBlock` per spec).
203///
204/// Per MCP 2025-11-25, the union is:
205/// `TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent`
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
207#[serde(tag = "type")]
208pub enum SamplingContent {
209    /// Text content
210    #[serde(rename = "text")]
211    Text(TextContent),
212    /// Image content (base64 encoded)
213    #[serde(rename = "image")]
214    Image(ImageContent),
215    /// Audio content (base64 encoded)
216    #[serde(rename = "audio")]
217    Audio(AudioContent),
218    /// Tool use content (assistant requesting tool invocation)
219    #[serde(rename = "tool_use")]
220    ToolUse(ToolUseContent),
221    /// Tool result content (result of a tool invocation)
222    #[serde(rename = "tool_result")]
223    ToolResult(ToolResultContent),
224}
225
226impl Default for SamplingContent {
227    fn default() -> Self {
228        Self::text("")
229    }
230}
231
232impl SamplingContent {
233    /// Create text content.
234    #[must_use]
235    pub fn text(text: impl Into<String>) -> Self {
236        Self::Text(TextContent {
237            text: text.into(),
238            annotations: None,
239            meta: None,
240        })
241    }
242
243    /// Get the text if this is text content.
244    #[must_use]
245    pub fn as_text(&self) -> Option<&str> {
246        match self {
247            Self::Text(t) => Some(&t.text),
248            _ => None,
249        }
250    }
251}
252
253/// Wrapper that deserializes as either a single content block or an array.
254///
255/// Per MCP 2025-11-25, `SamplingMessage.content` is
256/// `SamplingMessageContentBlock | SamplingMessageContentBlock[]`.
257///
258/// `Serialize` and `Deserialize` are implemented manually below to handle
259/// the single-vs-array polymorphism (single serializes as object, array as array).
260#[derive(Debug, Clone, PartialEq)]
261pub enum SamplingContentBlock {
262    /// A single content block.
263    Single(SamplingContent),
264    /// Multiple content blocks.
265    Multiple(Vec<SamplingContent>),
266}
267
268impl Default for SamplingContentBlock {
269    fn default() -> Self {
270        Self::Single(SamplingContent::default())
271    }
272}
273
274impl SamplingContentBlock {
275    /// Get the text of the first text content block, if any.
276    #[must_use]
277    pub fn as_text(&self) -> Option<&str> {
278        match self {
279            Self::Single(c) => c.as_text(),
280            Self::Multiple(v) => v.iter().find_map(|c| c.as_text()),
281        }
282    }
283
284    /// Collect all content blocks into a `Vec` of references.
285    ///
286    /// Note: this allocates. For iteration, use `iter()` instead.
287    #[must_use]
288    pub fn to_vec(&self) -> Vec<&SamplingContent> {
289        match self {
290            Self::Single(c) => vec![c],
291            Self::Multiple(v) => v.iter().collect(),
292        }
293    }
294}
295
296impl Serialize for SamplingContentBlock {
297    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
298        match self {
299            Self::Single(c) => c.serialize(serializer),
300            Self::Multiple(v) => v.serialize(serializer),
301        }
302    }
303}
304
305impl<'de> Deserialize<'de> for SamplingContentBlock {
306    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
307        let value = Value::deserialize(deserializer)?;
308        if value.is_array() {
309            let v: Vec<SamplingContent> =
310                serde_json::from_value(value).map_err(serde::de::Error::custom)?;
311            Ok(Self::Multiple(v))
312        } else {
313            let c: SamplingContent =
314                serde_json::from_value(value).map_err(serde::de::Error::custom)?;
315            Ok(Self::Single(c))
316        }
317    }
318}
319
320impl From<SamplingContent> for SamplingContentBlock {
321    fn from(c: SamplingContent) -> Self {
322        Self::Single(c)
323    }
324}
325
326impl From<Vec<SamplingContent>> for SamplingContentBlock {
327    fn from(v: Vec<SamplingContent>) -> Self {
328        Self::Multiple(v)
329    }
330}
331
332// =============================================================================
333// Individual content types
334// =============================================================================
335
336/// Text content with optional annotations.
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
338pub struct TextContent {
339    /// The text content
340    pub text: String,
341    /// Optional annotations (audience, priority, etc.)
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub annotations: Option<Annotations>,
344    /// Extension metadata
345    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
346    pub meta: Option<HashMap<String, Value>>,
347}
348
349impl TextContent {
350    /// Create new text content.
351    #[must_use]
352    pub fn new(text: impl Into<String>) -> Self {
353        Self {
354            text: text.into(),
355            annotations: None,
356            meta: None,
357        }
358    }
359}
360
361/// Image content (base64 encoded).
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363pub struct ImageContent {
364    /// Base64-encoded image data
365    pub data: String,
366    /// MIME type of the image
367    #[serde(rename = "mimeType")]
368    pub mime_type: String,
369    /// Optional annotations
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub annotations: Option<Annotations>,
372    /// Extension metadata
373    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
374    pub meta: Option<HashMap<String, Value>>,
375}
376
377/// Audio content (base64 encoded).
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379pub struct AudioContent {
380    /// Base64-encoded audio data
381    pub data: String,
382    /// MIME type of the audio
383    #[serde(rename = "mimeType")]
384    pub mime_type: String,
385    /// Optional annotations
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub annotations: Option<Annotations>,
388    /// Extension metadata
389    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
390    pub meta: Option<HashMap<String, Value>>,
391}
392
393/// Tool use content in a sampling message (assistant requesting tool invocation).
394///
395/// New in MCP 2025-11-25. Part of `SamplingMessageContentBlock`.
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct ToolUseContent {
398    /// Unique ID for this tool use.
399    pub id: String,
400    /// Name of the tool to invoke.
401    pub name: String,
402    /// Input arguments for the tool.
403    pub input: HashMap<String, Value>,
404    /// Extension metadata
405    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
406    pub meta: Option<HashMap<String, Value>>,
407}
408
409/// Tool result content in a sampling message (result of a tool invocation).
410///
411/// New in MCP 2025-11-25. Part of `SamplingMessageContentBlock`.
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
413pub struct ToolResultContent {
414    /// ID of the tool use this result corresponds to.
415    #[serde(rename = "toolUseId")]
416    pub tool_use_id: String,
417    /// Content blocks from the tool result.
418    pub content: Vec<Content>,
419    /// Structured content conforming to the tool's output schema.
420    #[serde(rename = "structuredContent", skip_serializing_if = "Option::is_none")]
421    pub structured_content: Option<Value>,
422    /// Whether the tool execution resulted in an error.
423    #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
424    pub is_error: Option<bool>,
425    /// Extension metadata
426    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
427    pub meta: Option<HashMap<String, Value>>,
428}
429
430/// A resource link (reference without embedding contents).
431///
432/// New in MCP 2025-11-25. Extends `Resource` with `type: "resource_link"`.
433/// Resource links returned by tools are not guaranteed to appear in `resources/list`.
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
435pub struct ResourceLink {
436    /// Resource URI
437    pub uri: String,
438    /// Resource name
439    pub name: String,
440    /// Resource description
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub description: Option<String>,
443    /// Human-readable title
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub title: Option<String>,
446    /// Resource icons
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub icons: Option<Vec<crate::definitions::Icon>>,
449    /// MIME type of the resource content
450    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
451    pub mime_type: Option<String>,
452    /// Resource annotations
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub annotations: Option<crate::definitions::ResourceAnnotations>,
455    /// Size in bytes (if known)
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub size: Option<u64>,
458    /// Extension metadata
459    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
460    pub meta: Option<HashMap<String, Value>>,
461}
462
463/// Embedded resource content in a message.
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465pub struct EmbeddedResource {
466    /// The actual resource contents
467    pub resource: ResourceContents,
468    /// Optional annotations
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub annotations: Option<Annotations>,
471    /// Extension metadata
472    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
473    pub meta: Option<HashMap<String, Value>>,
474}
475
476// =============================================================================
477// Resource contents
478// =============================================================================
479
480/// Contents of a resource (text or binary).
481#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
482#[serde(untagged)]
483pub enum ResourceContents {
484    /// Text resource content
485    Text(TextResourceContents),
486    /// Binary resource content
487    Blob(BlobResourceContents),
488}
489
490/// Backward-compatibility alias — canonical name is [`ResourceContents`].
491pub type ResourceContent = ResourceContents;
492
493impl ResourceContents {
494    /// Get the URI of this resource content.
495    #[must_use]
496    pub fn uri(&self) -> &str {
497        match self {
498            Self::Text(t) => &t.uri,
499            Self::Blob(b) => &b.uri,
500        }
501    }
502
503    /// Get the text content, if this is a text resource.
504    #[must_use]
505    pub fn text(&self) -> Option<&str> {
506        match self {
507            Self::Text(t) => Some(&t.text),
508            Self::Blob(_) => None,
509        }
510    }
511
512    /// Get the blob (base64) content, if this is a binary resource.
513    #[must_use]
514    pub fn blob(&self) -> Option<&str> {
515        match self {
516            Self::Text(_) => None,
517            Self::Blob(b) => Some(&b.blob),
518        }
519    }
520
521    /// Get the MIME type, if set.
522    #[must_use]
523    pub fn mime_type(&self) -> Option<&str> {
524        match self {
525            Self::Text(t) => t.mime_type.as_deref(),
526            Self::Blob(b) => b.mime_type.as_deref(),
527        }
528    }
529}
530
531/// Textual resource contents.
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
533pub struct TextResourceContents {
534    /// Resource URI
535    pub uri: String,
536    /// MIME type
537    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
538    pub mime_type: Option<String>,
539    /// Text content
540    pub text: String,
541    /// Extension metadata
542    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
543    pub meta: Option<HashMap<String, Value>>,
544}
545
546/// Binary resource contents.
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548pub struct BlobResourceContents {
549    /// Resource URI
550    pub uri: String,
551    /// MIME type
552    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
553    pub mime_type: Option<String>,
554    /// Base64-encoded binary data
555    pub blob: String,
556    /// Extension metadata
557    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
558    pub meta: Option<HashMap<String, Value>>,
559}
560
561// =============================================================================
562// Annotations
563// =============================================================================
564
565/// Annotations for content providing metadata.
566///
567/// Per MCP 2025-11-25, annotations indicate:
568/// - Who should see the content (audience)
569/// - Relative importance (priority)
570/// - When it was last modified
571#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
572pub struct Annotations {
573    /// Target audience for this content
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub audience: Option<Vec<Role>>,
576    /// Priority level (0.0 to 1.0, higher = more important)
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub priority: Option<f64>,
579    /// Last modified timestamp (ISO 8601)
580    #[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")]
581    pub last_modified: Option<String>,
582}
583
584impl Annotations {
585    /// Create annotations for user audience only.
586    #[must_use]
587    pub fn for_user() -> Self {
588        Self {
589            audience: Some(vec![Role::User]),
590            ..Default::default()
591        }
592    }
593
594    /// Create annotations for assistant audience only.
595    #[must_use]
596    pub fn for_assistant() -> Self {
597        Self {
598            audience: Some(vec![Role::Assistant]),
599            ..Default::default()
600        }
601    }
602
603    /// Set the priority level.
604    #[must_use]
605    pub fn with_priority(mut self, priority: f64) -> Self {
606        self.priority = Some(priority);
607        self
608    }
609
610    /// Set the last modified timestamp.
611    #[must_use]
612    pub fn with_last_modified(mut self, timestamp: impl Into<String>) -> Self {
613        self.last_modified = Some(timestamp.into());
614        self
615    }
616}
617
618// =============================================================================
619// Message (PromptMessage per spec)
620// =============================================================================
621
622/// A message in a prompt (`PromptMessage` per spec).
623///
624/// Per MCP 2025-11-25, `content` is a single `ContentBlock` (not an array).
625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
626pub struct Message {
627    /// The role of this message (user or assistant)
628    pub role: Role,
629    /// The content of this message (single ContentBlock)
630    pub content: Content,
631}
632
633impl Message {
634    /// Create a new message.
635    #[must_use]
636    pub fn new(role: Role, content: Content) -> Self {
637        Self { role, content }
638    }
639
640    /// Create a user message with text content.
641    #[must_use]
642    pub fn user(text: impl Into<String>) -> Self {
643        Self {
644            role: Role::User,
645            content: Content::text(text),
646        }
647    }
648
649    /// Create an assistant message with text content.
650    #[must_use]
651    pub fn assistant(text: impl Into<String>) -> Self {
652        Self {
653            role: Role::Assistant,
654            content: Content::text(text),
655        }
656    }
657
658    /// Check if this is a user message.
659    #[must_use]
660    pub fn is_user(&self) -> bool {
661        self.role == Role::User
662    }
663
664    /// Check if this is an assistant message.
665    #[must_use]
666    pub fn is_assistant(&self) -> bool {
667        self.role == Role::Assistant
668    }
669}
670
671/// Spec-aligned alias for [`Message`].
672///
673/// MCP 2025-11-25 calls this `PromptMessage`. The Rust name `Message` is
674/// shorter; this alias makes both names available.
675pub type PromptMessage = Message;
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn test_content_text() {
683        let content = Content::text("Hello");
684        assert!(content.is_text());
685        assert_eq!(content.as_text(), Some("Hello"));
686    }
687
688    #[test]
689    fn test_content_image() {
690        let content = Content::image("base64data", "image/png");
691        assert!(content.is_image());
692        assert!(!content.is_text());
693    }
694
695    #[test]
696    fn test_content_serde() {
697        let content = Content::text("Hello");
698        let json = serde_json::to_string(&content).unwrap();
699        assert!(json.contains("\"type\":\"text\""));
700        assert!(json.contains("\"text\":\"Hello\""));
701    }
702
703    #[test]
704    fn test_resource_link_serde() {
705        let link = Content::ResourceLink(ResourceLink {
706            uri: "file:///test.txt".into(),
707            name: "test".into(),
708            description: None,
709            title: None,
710            icons: None,
711            mime_type: Some("text/plain".into()),
712            annotations: None,
713            size: None,
714            meta: None,
715        });
716        let json = serde_json::to_string(&link).unwrap();
717        assert!(json.contains("\"type\":\"resource_link\""));
718        assert!(json.contains("\"uri\":\"file:///test.txt\""));
719
720        // Round-trip
721        let parsed: Content = serde_json::from_str(&json).unwrap();
722        assert!(parsed.is_resource_link());
723    }
724
725    #[test]
726    fn test_sampling_content_tool_use_serde() {
727        let content = SamplingContent::ToolUse(ToolUseContent {
728            id: "tu_1".into(),
729            name: "search".into(),
730            input: [("query".to_string(), Value::String("test".into()))].into(),
731            meta: None,
732        });
733        let json = serde_json::to_string(&content).unwrap();
734        assert!(json.contains("\"type\":\"tool_use\""));
735        assert!(json.contains("\"id\":\"tu_1\""));
736
737        let parsed: SamplingContent = serde_json::from_str(&json).unwrap();
738        assert!(matches!(parsed, SamplingContent::ToolUse(_)));
739    }
740
741    #[test]
742    fn test_sampling_content_block_single() {
743        let block = SamplingContentBlock::Single(SamplingContent::text("hello"));
744        let json = serde_json::to_string(&block).unwrap();
745        // Single should serialize as an object, not array
746        assert!(json.starts_with('{'));
747        let parsed: SamplingContentBlock = serde_json::from_str(&json).unwrap();
748        assert!(matches!(parsed, SamplingContentBlock::Single(_)));
749    }
750
751    #[test]
752    fn test_sampling_content_block_multiple() {
753        let block = SamplingContentBlock::Multiple(vec![
754            SamplingContent::text("hello"),
755            SamplingContent::text("world"),
756        ]);
757        let json = serde_json::to_string(&block).unwrap();
758        // Multiple should serialize as an array
759        assert!(json.starts_with('['));
760        let parsed: SamplingContentBlock = serde_json::from_str(&json).unwrap();
761        assert!(matches!(parsed, SamplingContentBlock::Multiple(v) if v.len() == 2));
762    }
763
764    #[test]
765    fn test_message_user() {
766        let msg = Message::user("Hello");
767        assert!(msg.is_user());
768        assert!(!msg.is_assistant());
769    }
770
771    #[test]
772    fn test_message_assistant() {
773        let msg = Message::assistant("Hi there");
774        assert!(msg.is_assistant());
775        assert!(!msg.is_user());
776    }
777
778    #[test]
779    fn test_annotations_for_user() {
780        let ann = Annotations::for_user().with_priority(1.0);
781        assert_eq!(ann.audience, Some(vec![Role::User]));
782        assert_eq!(ann.priority, Some(1.0));
783    }
784
785    #[test]
786    fn test_content_with_annotations() {
787        let content = Content::text("Hello").with_annotations(Annotations::for_user());
788        if let Content::Text(t) = content {
789            assert!(t.annotations.is_some());
790        } else {
791            panic!("Expected text content");
792        }
793    }
794
795    // C-1: ResourceContents untagged deserialization disambiguation
796    #[test]
797    fn test_resource_contents_text_deser() {
798        let json = r#"{"uri":"file:///test.txt","mimeType":"text/plain","text":"hello"}"#;
799        let rc: ResourceContents = serde_json::from_str(json).unwrap();
800        assert!(matches!(rc, ResourceContents::Text(_)));
801        assert_eq!(rc.uri(), "file:///test.txt");
802        assert_eq!(rc.text(), Some("hello"));
803        assert!(rc.blob().is_none());
804    }
805
806    #[test]
807    fn test_resource_contents_blob_deser() {
808        let json = r#"{"uri":"file:///img.png","mimeType":"image/png","blob":"aGVsbG8="}"#;
809        let rc: ResourceContents = serde_json::from_str(json).unwrap();
810        assert!(matches!(rc, ResourceContents::Blob(_)));
811        assert_eq!(rc.uri(), "file:///img.png");
812        assert_eq!(rc.blob(), Some("aGVsbG8="));
813        assert!(rc.text().is_none());
814    }
815
816    #[test]
817    fn test_resource_contents_round_trip() {
818        let text = ResourceContents::Text(TextResourceContents {
819            uri: "file:///a.txt".into(),
820            mime_type: Some("text/plain".into()),
821            text: "content".into(),
822            meta: None,
823        });
824        let json = serde_json::to_string(&text).unwrap();
825        let parsed: ResourceContents = serde_json::from_str(&json).unwrap();
826        assert_eq!(text, parsed);
827
828        let blob = ResourceContents::Blob(BlobResourceContents {
829            uri: "file:///b.bin".into(),
830            mime_type: Some("application/octet-stream".into()),
831            blob: "AQID".into(),
832            meta: None,
833        });
834        let json = serde_json::to_string(&blob).unwrap();
835        let parsed: ResourceContents = serde_json::from_str(&json).unwrap();
836        assert_eq!(blob, parsed);
837    }
838
839    // C-2: ToolResultContent serde round-trip
840    #[test]
841    fn test_sampling_content_tool_result_serde() {
842        let content = SamplingContent::ToolResult(ToolResultContent {
843            tool_use_id: "tu_1".into(),
844            content: vec![Content::text("result data")],
845            structured_content: Some(serde_json::json!({"key": "value"})),
846            is_error: Some(false),
847            meta: None,
848        });
849        let json = serde_json::to_string(&content).unwrap();
850        assert!(json.contains("\"type\":\"tool_result\""));
851        assert!(json.contains("\"toolUseId\":\"tu_1\""));
852        assert!(json.contains("\"structuredContent\""));
853
854        let parsed: SamplingContent = serde_json::from_str(&json).unwrap();
855        assert!(matches!(parsed, SamplingContent::ToolResult(_)));
856    }
857
858    // H-1: SamplingContentBlock empty array
859    #[test]
860    fn test_sampling_content_block_empty_array() {
861        let parsed: SamplingContentBlock = serde_json::from_str("[]").unwrap();
862        assert!(matches!(parsed, SamplingContentBlock::Multiple(v) if v.is_empty()));
863    }
864
865    // H-2: Single-element array vs single object
866    #[test]
867    fn test_sampling_content_block_single_element_array() {
868        let single_obj = r#"{"type":"text","text":"x"}"#;
869        let single_arr = r#"[{"type":"text","text":"x"}]"#;
870
871        let parsed_obj: SamplingContentBlock = serde_json::from_str(single_obj).unwrap();
872        assert!(matches!(parsed_obj, SamplingContentBlock::Single(_)));
873
874        let parsed_arr: SamplingContentBlock = serde_json::from_str(single_arr).unwrap();
875        assert!(matches!(parsed_arr, SamplingContentBlock::Multiple(v) if v.len() == 1));
876    }
877
878    // H-3: All Content type discriminants round-trip
879    #[test]
880    fn test_content_all_type_discriminants() {
881        let variants: Vec<(&str, Content)> = vec![
882            ("text", Content::text("hi")),
883            ("image", Content::image("data", "image/png")),
884            ("audio", Content::audio("data", "audio/wav")),
885            (
886                "resource_link",
887                Content::ResourceLink(ResourceLink {
888                    uri: "file:///x".into(),
889                    name: "x".into(),
890                    description: None,
891                    title: None,
892                    icons: None,
893                    mime_type: None,
894                    annotations: None,
895                    size: None,
896                    meta: None,
897                }),
898            ),
899            ("resource", Content::resource("file:///x", "text")),
900        ];
901
902        for (expected_type, content) in variants {
903            let json = serde_json::to_string(&content).unwrap();
904            assert!(
905                json.contains(&format!("\"type\":\"{}\"", expected_type)),
906                "Missing type discriminant for {expected_type}: {json}"
907            );
908            let parsed: Content = serde_json::from_str(&json).unwrap();
909            assert_eq!(content, parsed, "Round-trip failed for {expected_type}");
910        }
911    }
912
913    // H-4: _meta field serialization presence/absence
914    #[test]
915    fn test_meta_field_skip_serializing_if_none() {
916        let content = TextContent::new("hello");
917        let json = serde_json::to_string(&content).unwrap();
918        assert!(!json.contains("_meta"), "None meta should be omitted");
919
920        let mut meta = HashMap::new();
921        meta.insert("key".into(), Value::String("val".into()));
922        let content = TextContent {
923            text: "hello".into(),
924            annotations: None,
925            meta: Some(meta),
926        };
927        let json = serde_json::to_string(&content).unwrap();
928        assert!(json.contains("\"_meta\""), "Some meta should be present");
929    }
930
931    // H-4: _meta on ResourceContents
932    #[test]
933    fn test_resource_contents_meta_field() {
934        let mut meta = HashMap::new();
935        meta.insert("k".into(), Value::Bool(true));
936        let rc = ResourceContents::Text(TextResourceContents {
937            uri: "x".into(),
938            mime_type: None,
939            text: "y".into(),
940            meta: Some(meta),
941        });
942        let json = serde_json::to_string(&rc).unwrap();
943        assert!(json.contains("\"_meta\""));
944        let parsed: ResourceContents = serde_json::from_str(&json).unwrap();
945        assert_eq!(rc, parsed);
946    }
947
948    // H-6: SamplingContentBlock to_vec
949    #[test]
950    fn test_sampling_content_block_to_vec() {
951        let single = SamplingContentBlock::Single(SamplingContent::text("a"));
952        assert_eq!(single.to_vec().len(), 1);
953
954        let multi = SamplingContentBlock::Multiple(vec![
955            SamplingContent::text("a"),
956            SamplingContent::text("b"),
957        ]);
958        assert_eq!(multi.to_vec().len(), 2);
959
960        // as_text on Multiple finds first text
961        assert_eq!(multi.as_text(), Some("a"));
962    }
963}