feat!: align model types with MCP 2025-11-25 spec (#927)

* feat!: align model types with MCP 2025-11-25 spec

* fix: complete 2025-11-25 model spec conformance
This commit is contained in:
Dale Seo 2026-06-24 20:41:13 -04:00 • committed by GitHub
commit 77932141e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 4321 additions and 3886 deletions

View file

@ -1,13 +1,15 @@
use std::ops::{Deref, DerefMut};
//! Annotations for content blocks and resources.
//!
//! The `Annotations` struct carries optional hints about audience, priority, and freshness.
//! Individual content/resource types embed `annotations: Option<Annotations>` directly.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::{
RawAudioContent, RawContent, RawEmbeddedResource, RawImageContent, RawResource,
RawResourceTemplate, RawTextContent, Role,
};
use super::Role;
/// Optional annotations for the client. The client can use annotations to inform how objects are
/// used or displayed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@ -35,192 +37,23 @@ impl Annotations {
audience: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct Annotated<T: AnnotateAble> {
#[serde(flatten)]
pub raw: T,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub fn with_audience(mut self, audience: Vec<Role>) -> Self {
self.audience = Some(audience);
self
}
impl<T: AnnotateAble> Deref for Annotated<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.raw
pub fn with_priority(mut self, priority: f32) -> Self {
self.priority = Some(priority);
self
}
}
impl<T: AnnotateAble> DerefMut for Annotated<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.raw
pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
self.last_modified = Some(timestamp);
self
}
}
impl<T: AnnotateAble> Annotated<T> {
pub fn new(raw: T, annotations: Option<Annotations>) -> Self {
Self { raw, annotations }
}
pub fn remove_annotation(&mut self) -> Option<Annotations> {
self.annotations.take()
}
pub fn audience(&self) -> Option<&Vec<Role>> {
self.annotations.as_ref().and_then(|a| a.audience.as_ref())
}
pub fn priority(&self) -> Option<f32> {
self.annotations.as_ref().and_then(|a| a.priority)
}
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
self.annotations.as_ref().and_then(|a| a.last_modified)
}
pub fn with_audience(self, audience: Vec<Role>) -> Annotated<T>
where
Self: Sized,
{
if let Some(annotations) = self.annotations {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
audience: Some(audience),
..annotations
}),
}
} else {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
audience: Some(audience),
priority: None,
last_modified: None,
}),
}
}
}
pub fn with_priority(self, priority: f32) -> Annotated<T>
where
Self: Sized,
{
if let Some(annotations) = self.annotations {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
priority: Some(priority),
..annotations
}),
}
} else {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
priority: Some(priority),
last_modified: None,
audience: None,
}),
}
}
}
pub fn with_timestamp(self, timestamp: DateTime<Utc>) -> Annotated<T>
where
Self: Sized,
{
if let Some(annotations) = self.annotations {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
last_modified: Some(timestamp),
..annotations
}),
}
} else {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
last_modified: Some(timestamp),
priority: None,
audience: None,
}),
}
}
}
pub fn with_timestamp_now(self) -> Annotated<T>
where
Self: Sized,
{
self.with_timestamp(Utc::now())
}
}
mod sealed {
pub trait Sealed {}
}
macro_rules! annotate {
($T: ident) => {
impl sealed::Sealed for $T {}
impl AnnotateAble for $T {}
};
}
annotate!(RawContent);
annotate!(RawTextContent);
annotate!(RawImageContent);
annotate!(RawAudioContent);
annotate!(RawEmbeddedResource);
annotate!(RawResource);
annotate!(RawResourceTemplate);
pub trait AnnotateAble: sealed::Sealed {
fn optional_annotate(self, annotations: Option<Annotations>) -> Annotated<Self>
where
Self: Sized,
{
Annotated::new(self, annotations)
}
fn annotate(self, annotations: Annotations) -> Annotated<Self>
where
Self: Sized,
{
Annotated::new(self, Some(annotations))
}
fn no_annotation(self) -> Annotated<Self>
where
Self: Sized,
{
Annotated::new(self, None)
}
fn with_audience(self, audience: Vec<Role>) -> Annotated<Self>
where
Self: Sized,
{
self.annotate(Annotations {
audience: Some(audience),
..Default::default()
})
}
fn with_priority(self, priority: f32) -> Annotated<Self>
where
Self: Sized,
{
self.annotate(Annotations {
priority: Some(priority),
..Default::default()
})
}
fn with_timestamp(self, timestamp: DateTime<Utc>) -> Annotated<Self>
where
Self: Sized,
{
self.annotate(Annotations {
last_modified: Some(timestamp),
..Default::default()
})
}
fn with_timestamp_now(self) -> Annotated<Self>
where
Self: Sized,
{
pub fn with_timestamp_now(self) -> Self {
self.with_timestamp(Utc::now())
}
}

View file

@ -34,7 +34,7 @@ pub type ExtensionCapabilities = BTreeMap<String, JsonObject>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct PromptsCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
@ -43,7 +43,7 @@ pub struct PromptsCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ResourcesCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub subscribe: Option<bool>,
@ -54,7 +54,7 @@ pub struct ResourcesCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ToolsCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
@ -66,7 +66,7 @@ pub struct ToolsCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct RootsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
@ -76,7 +76,7 @@ pub struct RootsCapabilities {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct TasksCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub requests: Option<TaskRequestsCapability>,
@ -90,7 +90,7 @@ pub struct TasksCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct TaskRequestsCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub sampling: Option<SamplingTaskCapability>,
@ -106,7 +106,7 @@ pub struct TaskRequestsCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct SamplingTaskCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub create_message: Option<JsonObject>,
@ -115,7 +115,7 @@ pub struct SamplingTaskCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ElicitationTaskCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub create: Option<JsonObject>,
@ -124,7 +124,7 @@ pub struct ElicitationTaskCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ToolsTaskCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub call: Option<JsonObject>,
@ -205,7 +205,7 @@ impl TasksCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct FormElicitationCapability {
/// Whether the client supports JSON Schema validation for elicitation responses.
/// When true, the client will validate user input against the requested_schema
@ -214,19 +214,36 @@ pub struct FormElicitationCapability {
pub schema_validation: Option<bool>,
}
impl FormElicitationCapability {
pub fn new() -> Self {
Self::default()
}
pub fn with_schema_validation(mut self, enabled: bool) -> Self {
self.schema_validation = Some(enabled);
self
}
}
/// Capability for URL mode elicitation.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct UrlElicitationCapability {}
impl UrlElicitationCapability {
pub fn new() -> Self {
Self::default()
}
}
/// Elicitation allows servers to request interactive input from users during tool execution.
/// This capability indicates that a client can handle elicitation requests and present
/// appropriate UI to users for collecting the requested information.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ElicitationCapability {
/// Whether client supports form-based elicitation.
#[serde(skip_serializing_if = "Option::is_none")]
@ -236,6 +253,22 @@ pub struct ElicitationCapability {
pub url: Option<UrlElicitationCapability>,
}
impl ElicitationCapability {
pub fn new() -> Self {
Self::default()
}
pub fn with_form(mut self, form: FormElicitationCapability) -> Self {
self.form = Some(form);
self
}
pub fn with_url(mut self, url: UrlElicitationCapability) -> Self {
self.url = Some(url);
self
}
}
/// Sampling capability with optional sub-capabilities (SEP-1577).
///
/// Deprecated by SEP-2577; remains functional and will be removed in a future
@ -244,7 +277,7 @@ pub struct ElicitationCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct SamplingCapability {
/// Support for `tools` and `toolChoice` parameters
#[serde(skip_serializing_if = "Option::is_none")]

View file

@ -1,78 +1,177 @@
//! Content sent around agents, extensions, and LLMs
//! The various content types can be display to humans but also understood by models
//! They include optional annotations used to help inform agent usage
//! Content types that flow between agents, tools, prompts, and LLMs.
//!
//! The core union is [`ContentBlock`] (text | image | audio | resource_link | resource),
//! matching the MCP 2025-11-25 `ContentBlock` definition. Each variant carries optional
//! [`Annotations`] and `_meta` inline.
//!
//! [`SamplingMessageContentBlock`] extends the union with `tool_use` and `tool_result`
//! variants for sampling messages (SEP-1577).
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::{AnnotateAble, Annotated, resource::ResourceContents};
use super::{Annotations, Meta, resource::ResourceContents};
// ---------------------------------------------------------------------------
// Flat content structs
// ---------------------------------------------------------------------------
/// Text content block (spec `TextContent`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawTextContent {
#[non_exhaustive]
pub struct TextContent {
/// The text content of the message.
pub text: String,
/// Optional protocol-level metadata for this content block
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
}
pub type TextContent = Annotated<RawTextContent>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawImageContent {
/// The base64-encoded image
pub data: String,
pub mime_type: String,
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub type ImageContent = Annotated<RawImageContent>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawEmbeddedResource {
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
pub resource: ResourceContents,
}
impl RawEmbeddedResource {
/// Create a new RawEmbeddedResource.
pub fn new(resource: ResourceContents) -> Self {
impl TextContent {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
meta: None,
resource,
annotations: None,
}
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
pub type EmbeddedResource = Annotated<RawEmbeddedResource>;
/// Image content with base64-encoded data (spec `ImageContent`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ImageContent {
/// The base64-encoded image data.
pub data: String,
/// The MIME type of the image (e.g. `image/png`).
pub mime_type: String,
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl ImageContent {
pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
data: data.into(),
mime_type: mime_type.into(),
meta: None,
annotations: None,
}
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// Audio content with base64-encoded data (spec `AudioContent`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct AudioContent {
/// The base64-encoded audio data.
pub data: String,
/// The MIME type of the audio (e.g. `audio/wav`).
pub mime_type: String,
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl AudioContent {
pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
data: data.into(),
mime_type: mime_type.into(),
meta: None,
annotations: None,
}
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// Embedded resource content (spec `EmbeddedResource`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct EmbeddedResource {
/// The embedded resource contents (text or blob).
pub resource: ResourceContents,
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl EmbeddedResource {
pub fn new(resource: ResourceContents) -> Self {
Self {
resource,
meta: None,
annotations: None,
}
}
pub fn get_text(&self) -> String {
match &self.resource {
ResourceContents::TextResourceContents { text, .. } => text.clone(),
_ => String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawAudioContent {
pub data: String,
pub mime_type: String,
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub type AudioContent = Annotated<RawAudioContent>;
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// Tool call request from assistant (SEP-1577).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -80,15 +179,11 @@ pub type AudioContent = Annotated<RawAudioContent>;
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ToolUseContent {
/// Unique identifier for this tool call
pub id: String,
/// Name of the tool to call
pub name: String,
/// Input arguments for the tool
pub input: super::JsonObject,
/// Optional metadata (preserved for caching)
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
pub meta: Option<Meta>,
}
/// Tool execution result in user message (SEP-1577).
@ -97,18 +192,12 @@ pub struct ToolUseContent {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ToolResultContent {
/// Optional metadata
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
/// ID of the corresponding tool use
pub meta: Option<Meta>,
pub tool_use_id: String,
/// Content blocks returned by the tool
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<Content>,
/// Optional structured result
pub content: Vec<ContentBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<super::JsonObject>,
/// Whether tool execution failed
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
@ -125,7 +214,7 @@ impl ToolUseContent {
}
impl ToolResultContent {
pub fn new(tool_use_id: impl Into<String>, content: Vec<Content>) -> Self {
pub fn new(tool_use_id: impl Into<String>, content: Vec<ContentBlock>) -> Self {
Self {
meta: None,
tool_use_id: tool_use_id.into(),
@ -135,7 +224,7 @@ impl ToolResultContent {
}
}
pub fn error(tool_use_id: impl Into<String>, content: Vec<Content>) -> Self {
pub fn error(tool_use_id: impl Into<String>, content: Vec<ContentBlock>) -> Self {
Self {
meta: None,
tool_use_id: tool_use_id.into(),
@ -146,21 +235,26 @@ impl ToolResultContent {
}
}
// ---------------------------------------------------------------------------
// ContentBlock — the unified content union (spec `ContentBlock`)
// ---------------------------------------------------------------------------
/// Unified content block union (spec `ContentBlock`).
///
/// `text | image | audio | resource_link | resource`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum RawContent {
Text(RawTextContent),
Image(RawImageContent),
Resource(RawEmbeddedResource),
Audio(RawAudioContent),
ResourceLink(super::resource::RawResource),
#[non_exhaustive]
pub enum ContentBlock {
Text(TextContent),
Image(ImageContent),
Audio(AudioContent),
Resource(EmbeddedResource),
ResourceLink(super::resource::Resource),
}
pub type Content = Annotated<RawContent>;
impl RawContent {
impl ContentBlock {
pub fn json<S: Serialize>(json: S) -> Result<Self, crate::ErrorData> {
let json = serde_json::to_string(&json).map_err(|e| {
crate::ErrorData::internal_error(
@ -170,129 +264,106 @@ impl RawContent {
)),
)
})?;
Ok(RawContent::text(json))
Ok(ContentBlock::text(json))
}
pub fn text<S: Into<String>>(text: S) -> Self {
RawContent::Text(RawTextContent {
text: text.into(),
meta: None,
})
pub fn text(text: impl Into<String>) -> Self {
ContentBlock::Text(TextContent::new(text))
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
RawContent::Image(RawImageContent {
data: data.into(),
mime_type: mime_type.into(),
meta: None,
})
pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
ContentBlock::Image(ImageContent::new(data, mime_type))
}
pub fn audio(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
ContentBlock::Audio(AudioContent::new(data, mime_type))
}
pub fn resource(resource: ResourceContents) -> Self {
RawContent::Resource(RawEmbeddedResource {
meta: None,
resource,
})
ContentBlock::Resource(EmbeddedResource::new(resource))
}
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
RawContent::Resource(RawEmbeddedResource {
meta: None,
resource: ResourceContents::TextResourceContents {
pub fn embedded_text(uri: impl Into<String>, content: impl Into<String>) -> Self {
ContentBlock::Resource(EmbeddedResource::new(
ResourceContents::TextResourceContents {
uri: uri.into(),
mime_type: Some("text".to_string()),
text: content.into(),
meta: None,
},
})
))
}
/// Get the text content if this is a TextContent variant
pub fn as_text(&self) -> Option<&RawTextContent> {
pub fn resource_link(resource: super::resource::Resource) -> Self {
ContentBlock::ResourceLink(resource)
}
pub fn as_text(&self) -> Option<&TextContent> {
match self {
RawContent::Text(text) => Some(text),
ContentBlock::Text(text) => Some(text),
_ => None,
}
}
/// Get the image content if this is an ImageContent variant
pub fn as_image(&self) -> Option<&RawImageContent> {
pub fn as_image(&self) -> Option<&ImageContent> {
match self {
RawContent::Image(image) => Some(image),
ContentBlock::Image(image) => Some(image),
_ => None,
}
}
/// Get the resource content if this is an ImageContent variant
pub fn as_resource(&self) -> Option<&RawEmbeddedResource> {
pub fn as_resource(&self) -> Option<&EmbeddedResource> {
match self {
RawContent::Resource(resource) => Some(resource),
ContentBlock::Resource(resource) => Some(resource),
_ => None,
}
}
/// Get the resource link if this is a ResourceLink variant
pub fn as_resource_link(&self) -> Option<&super::resource::RawResource> {
pub fn as_resource_link(&self) -> Option<&super::resource::Resource> {
match self {
RawContent::ResourceLink(link) => Some(link),
ContentBlock::ResourceLink(link) => Some(link),
_ => None,
}
}
/// Create a resource link content
pub fn resource_link(resource: super::resource::RawResource) -> Self {
RawContent::ResourceLink(resource)
pub fn as_audio(&self) -> Option<&AudioContent> {
match self {
ContentBlock::Audio(audio) => Some(audio),
_ => None,
}
}
}
impl Content {
pub fn text<S: Into<String>>(text: S) -> Self {
RawContent::text(text).no_annotation()
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
RawContent::image(data, mime_type).no_annotation()
}
pub fn resource(resource: ResourceContents) -> Self {
RawContent::resource(resource).no_annotation()
}
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
RawContent::embedded_text(uri, content).no_annotation()
}
pub fn json<S: Serialize>(json: S) -> Result<Self, crate::ErrorData> {
RawContent::json(json).map(|c| c.no_annotation())
}
/// Create a resource link content
pub fn resource_link(resource: super::resource::RawResource) -> Self {
RawContent::resource_link(resource).no_annotation()
}
}
// ---------------------------------------------------------------------------
// JsonContent (unchanged)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsonContent<S: Serialize>(S);
/// Types that can be converted into a list of contents
// ---------------------------------------------------------------------------
// IntoContents
// ---------------------------------------------------------------------------
/// Types that can be converted into a list of content blocks.
pub trait IntoContents {
fn into_contents(self) -> Vec<Content>;
fn into_contents(self) -> Vec<ContentBlock>;
}
impl IntoContents for Content {
fn into_contents(self) -> Vec<Content> {
impl IntoContents for ContentBlock {
fn into_contents(self) -> Vec<ContentBlock> {
vec![self]
}
}
impl IntoContents for String {
fn into_contents(self) -> Vec<Content> {
vec![Content::text(self)]
fn into_contents(self) -> Vec<ContentBlock> {
vec![ContentBlock::text(self)]
}
}
impl IntoContents for () {
fn into_contents(self) -> Vec<Content> {
fn into_contents(self) -> Vec<ContentBlock> {
vec![]
}
}
@ -305,40 +376,32 @@ mod tests {
#[test]
fn test_image_content_serialization() {
let image_content = RawImageContent {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
meta: None,
};
let json = serde_json::to_string(&image_content).unwrap();
println!("ImageContent JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
let image = ImageContent::new("base64data", "image/png");
let json = serde_json::to_string(&image).unwrap();
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_audio_content_serialization() {
let audio_content = RawAudioContent {
data: "base64audiodata".to_string(),
mime_type: "audio/wav".to_string(),
};
let json = serde_json::to_string(&audio_content).unwrap();
println!("AudioContent JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
let audio = AudioContent::new("base64audiodata", "audio/wav");
let json = serde_json::to_string(&audio).unwrap();
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_resource_link_serialization() {
use super::super::resource::RawResource;
fn test_audio_content_has_meta() {
let audio = AudioContent::new("data", "audio/wav").with_meta(Meta::default());
let json = serde_json::to_value(&audio).unwrap();
assert!(json.get("_meta").is_some());
}
let resource_link = RawContent::ResourceLink(RawResource {
#[test]
fn test_resource_link_serialization() {
use super::super::resource::Resource;
let resource_link = ContentBlock::ResourceLink(Resource {
uri: "file:///test.txt".to_string(),
name: "test.txt".to_string(),
title: None,
@ -347,12 +410,10 @@ mod tests {
size: Some(100),
icons: None,
meta: None,
annotations: None,
});
let json = serde_json::to_string(&resource_link).unwrap();
println!("ResourceLink JSON: {}", json);
// Verify it contains the correct type tag
assert!(json.contains("\"type\":\"resource_link\""));
assert!(json.contains("\"uri\":\"file:///test.txt\""));
assert!(json.contains("\"name\":\"test.txt\""));
@ -368,9 +429,9 @@ mod tests {
"mimeType": "text/plain"
}"#;
let content: RawContent = serde_json::from_str(json).unwrap();
let content: ContentBlock = serde_json::from_str(json).unwrap();
if let RawContent::ResourceLink(resource) = content {
if let ContentBlock::ResourceLink(resource) = content {
assert_eq!(resource.uri, "file:///example.txt");
assert_eq!(resource.name, "example.txt");
assert_eq!(resource.description, Some("Example file".to_string()));
@ -379,4 +440,15 @@ mod tests {
panic!("Expected ResourceLink variant");
}
}
#[test]
fn test_content_block_text_with_annotations() {
let block = ContentBlock::Text(
TextContent::new("hello").with_annotations(Annotations::default().with_priority(0.8)),
);
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
assert_eq!(json["annotations"]["priority"], 0.8_f32);
}
}

View file

@ -49,8 +49,8 @@ const_string!(ArrayTypeConst = "array");
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum PrimitiveSchema {
#[non_exhaustive]
pub enum PrimitiveSchemaDefinition {
/// Enum property (explicit enum schema)
Enum(EnumSchema),
/// String property (with optional enum constraint)
@ -63,6 +63,9 @@ pub enum PrimitiveSchema {
Boolean(BooleanSchema),
}
#[deprecated(since = "2.0.0", note = "Renamed to PrimitiveSchemaDefinition")]
pub type PrimitiveSchema = PrimitiveSchemaDefinition;
// =============================================================================
// STRING SCHEMA
// =============================================================================
@ -71,7 +74,7 @@ pub enum PrimitiveSchema {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum StringFormat {
/// Email address format
Email,
@ -346,7 +349,7 @@ impl NumberSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct IntegerSchema {
/// Type discriminator
#[serde(rename = "type")]
@ -513,7 +516,7 @@ impl BooleanSchema {
/// Represent single entry for titled item
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ConstTitle {
#[serde(rename = "const")]
pub const_: String,
@ -534,7 +537,7 @@ impl ConstTitle {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct LegacyEnumSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
@ -546,6 +549,21 @@ pub struct LegacyEnumSchema {
pub enum_: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enum_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
impl LegacyEnumSchema {
pub fn new(enum_values: Vec<String>) -> Self {
Self {
type_: StringTypeConst,
title: None,
description: None,
enum_: enum_values,
enum_names: None,
default: None,
}
}
}
/// Untitled single-select
@ -601,7 +619,7 @@ impl TitledSingleSelectEnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum SingleSelectEnumSchema {
Untitled(UntitledSingleSelectEnumSchema),
Titled(TitledSingleSelectEnumSchema),
@ -610,7 +628,7 @@ pub enum SingleSelectEnumSchema {
/// Items for untitled multi-select options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct UntitledItems {
#[serde(rename = "type")]
pub type_: StringTypeConst,
@ -618,10 +636,19 @@ pub struct UntitledItems {
pub enum_: Vec<String>,
}
impl UntitledItems {
pub fn new(enum_values: Vec<String>) -> Self {
Self {
type_: StringTypeConst,
enum_: enum_values,
}
}
}
/// Items for titled multi-select options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct TitledItems {
// MCP spec requires "anyOf" for multi-select enums (allows any combination)
// Alias "oneOf" for compatibility with schemars
@ -727,7 +754,7 @@ impl TitledMultiSelectEnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum MultiSelectEnumSchema {
Untitled(UntitledMultiSelectEnumSchema),
Titled(TitledMultiSelectEnumSchema),
@ -751,7 +778,7 @@ pub enum MultiSelectEnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum EnumSchema {
Single(SingleSelectEnumSchema),
Multi(MultiSelectEnumSchema),
@ -1090,7 +1117,7 @@ impl EnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ElicitationSchema {
/// Always "object" for elicitation schemas
#[serde(rename = "type")]
@ -1101,7 +1128,7 @@ pub struct ElicitationSchema {
pub title: Option<Cow<'static, str>>,
/// Property definitions (must be primitive types)
pub properties: BTreeMap<String, PrimitiveSchema>,
pub properties: BTreeMap<String, PrimitiveSchemaDefinition>,
/// List of required property names
#[serde(skip_serializing_if = "Option::is_none")]
@ -1114,7 +1141,7 @@ pub struct ElicitationSchema {
impl ElicitationSchema {
/// Create a new elicitation schema with the given properties
pub fn new(properties: BTreeMap<String, PrimitiveSchema>) -> Self {
pub fn new(properties: BTreeMap<String, PrimitiveSchemaDefinition>) -> Self {
Self {
type_: ObjectTypeConst,
title: None,
@ -1237,7 +1264,7 @@ impl ElicitationSchema {
#[derive(Debug, Default)]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct ElicitationSchemaBuilder {
pub properties: BTreeMap<String, PrimitiveSchema>,
pub properties: BTreeMap<String, PrimitiveSchemaDefinition>,
pub required: Vec<String>,
pub title: Option<Cow<'static, str>>,
pub description: Option<Cow<'static, str>>,
@ -1250,13 +1277,17 @@ impl ElicitationSchemaBuilder {
}
/// Add a property to the schema
pub fn property(mut self, name: impl Into<String>, schema: PrimitiveSchema) -> Self {
pub fn property(mut self, name: impl Into<String>, schema: PrimitiveSchemaDefinition) -> Self {
self.properties.insert(name.into(), schema);
self
}
/// Add a required property to the schema
pub fn required_property(mut self, name: impl Into<String>, schema: PrimitiveSchema) -> Self {
pub fn required_property(
mut self,
name: impl Into<String>,
schema: PrimitiveSchemaDefinition,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(name_str, schema);
@ -1264,7 +1295,7 @@ impl ElicitationSchemaBuilder {
}
// ===========================================================================
// TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchema wrapper
// TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchemaDefinition wrapper
// ===========================================================================
/// Add a string property with custom builder (required)
@ -1273,8 +1304,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.properties
.insert(name.into(), PrimitiveSchema::String(f(StringSchema::new())));
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
);
self
}
@ -1286,8 +1319,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::String(f(StringSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
);
self
}
@ -1297,8 +1332,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.properties
.insert(name.into(), PrimitiveSchema::Number(f(NumberSchema::new())));
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
);
self
}
@ -1310,8 +1347,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Number(f(NumberSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
);
self
}
@ -1323,7 +1362,7 @@ impl ElicitationSchemaBuilder {
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchema::Integer(f(IntegerSchema::new())),
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
);
self
}
@ -1336,8 +1375,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Integer(f(IntegerSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
);
self
}
@ -1349,7 +1390,7 @@ impl ElicitationSchemaBuilder {
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchema::Boolean(f(BooleanSchema::new())),
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
);
self
}
@ -1362,8 +1403,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Boolean(f(BooleanSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
);
self
}
@ -1373,22 +1416,28 @@ impl ElicitationSchemaBuilder {
/// Add a required string property
pub fn required_string(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::String(StringSchema::new()))
self.required_property(name, PrimitiveSchemaDefinition::String(StringSchema::new()))
}
/// Add an optional string property
pub fn optional_string(self, name: impl Into<String>) -> Self {
self.property(name, PrimitiveSchema::String(StringSchema::new()))
self.property(name, PrimitiveSchemaDefinition::String(StringSchema::new()))
}
/// Add a required email property
pub fn required_email(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::String(StringSchema::email()))
self.required_property(
name,
PrimitiveSchemaDefinition::String(StringSchema::email()),
)
}
/// Add an optional email property
pub fn optional_email(self, name: impl Into<String>) -> Self {
self.property(name, PrimitiveSchema::String(StringSchema::email()))
self.property(
name,
PrimitiveSchemaDefinition::String(StringSchema::email()),
)
}
/// Add a required string property with custom builder
@ -1397,7 +1446,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::String(f(StringSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
)
}
/// Add an optional string property with custom builder
@ -1406,7 +1458,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.property(name, PrimitiveSchema::String(f(StringSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
)
}
// Convenience methods for numbers
@ -1415,7 +1470,7 @@ impl ElicitationSchemaBuilder {
pub fn required_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.required_property(
name,
PrimitiveSchema::Number(NumberSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)),
)
}
@ -1423,7 +1478,7 @@ impl ElicitationSchemaBuilder {
pub fn optional_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.property(
name,
PrimitiveSchema::Number(NumberSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)),
)
}
@ -1433,7 +1488,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Number(f(NumberSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
)
}
/// Add an optional number property with custom builder
@ -1442,7 +1500,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.property(name, PrimitiveSchema::Number(f(NumberSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
)
}
// Convenience methods for integers
@ -1451,7 +1512,7 @@ impl ElicitationSchemaBuilder {
pub fn required_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.required_property(
name,
PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)),
)
}
@ -1459,7 +1520,7 @@ impl ElicitationSchemaBuilder {
pub fn optional_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.property(
name,
PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)),
)
}
@ -1469,7 +1530,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Integer(f(IntegerSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
)
}
/// Add an optional integer property with custom builder
@ -1478,21 +1542,27 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.property(name, PrimitiveSchema::Integer(f(IntegerSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
)
}
// Convenience methods for booleans
/// Add a required boolean property
pub fn required_bool(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::Boolean(BooleanSchema::new()))
self.required_property(
name,
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
}
/// Add an optional boolean property with default value
pub fn optional_bool(self, name: impl Into<String>, default: bool) -> Self {
self.property(
name,
PrimitiveSchema::Boolean(BooleanSchema::new().with_default(default)),
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new().with_default(default)),
)
}
@ -1502,7 +1572,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
)
}
/// Add an optional boolean property with custom builder
@ -1511,19 +1584,22 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
)
}
// Enum convenience methods
/// Add a required enum property using EnumSchema
pub fn required_enum_schema(self, name: impl Into<String>, enum_schema: EnumSchema) -> Self {
self.required_property(name, PrimitiveSchema::Enum(enum_schema))
self.required_property(name, PrimitiveSchemaDefinition::Enum(enum_schema))
}
/// Add an optional enum property using EnumSchema
pub fn optional_enum_schema(self, name: impl Into<String>, enum_schema: EnumSchema) -> Self {
self.property(name, PrimitiveSchema::Enum(enum_schema))
self.property(name, PrimitiveSchemaDefinition::Enum(enum_schema))
}
/// Add a required enum property using values. Creates an untitled single-select enum.
@ -1534,12 +1610,13 @@ impl ElicitationSchemaBuilder {
pub fn required_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.required_property(
name,
PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema {
PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: values,
enum_names: None,
default: None,
})),
)
}
@ -1552,12 +1629,13 @@ impl ElicitationSchemaBuilder {
pub fn optional_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.property(
name,
PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema {
PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: values,
enum_names: None,
default: None,
})),
)
}
@ -1732,6 +1810,7 @@ mod tests {
description: Some("A legacy enum schema".into()),
enum_: vec!["A".to_string(), "B".to_string()],
enum_names: Some(vec!["Option A".to_string(), "Option B".to_string()]),
default: None,
});
let json = serde_json::to_value(&schema)?;
@ -1776,6 +1855,7 @@ mod tests {
description: None,
enum_: vec!["a".to_string(), "b".to_string()],
enum_names: None,
default: None,
});
let json = serde_json::to_value(&schema)?;
assert!(!json.as_object().unwrap().contains_key("enumNames"));
@ -1970,14 +2050,14 @@ mod tests {
"type": "string",
"enum": ["a", "b"]
});
let schema: PrimitiveSchema = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchema::Enum(_)));
let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchemaDefinition::Enum(_)));
// Test that string schemas deserialize as String variant
let json = json!({
"type": "string"
});
let schema: PrimitiveSchema = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchema::String(_)));
let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchemaDefinition::String(_)));
}
#[test]

View file

@ -5,7 +5,7 @@ use serde_json::Value;
use super::{
ClientNotification, ClientRequest, CustomNotification, CustomRequest, Extensions, JsonObject,
JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest,
JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest, TaskMetadata,
};
pub trait GetMeta {
@ -54,11 +54,11 @@ pub trait RequestParamsMeta {
/// can include a `task` field to signal that the caller wants task-augmented execution.
pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta {
/// Get a reference to the task field
fn task(&self) -> Option<&JsonObject>;
fn task(&self) -> Option<&TaskMetadata>;
/// Get a mutable reference to the task field
fn task_mut(&mut self) -> &mut Option<JsonObject>;
fn task_mut(&mut self) -> &mut Option<TaskMetadata>;
/// Set the task field
fn set_task(&mut self, task: JsonObject) {
fn set_task(&mut self, task: TaskMetadata) {
*self.task_mut() = Some(task);
}
}
@ -152,9 +152,9 @@ variant_extension! {
CallToolRequest
ListToolsRequest
CustomRequest
GetTaskInfoRequest
GetTaskRequest
ListTasksRequest
GetTaskResultRequest
GetTaskPayloadRequest
CancelTaskRequest
}
}
@ -164,7 +164,7 @@ variant_extension! {
PingRequest
CreateMessageRequest
ListRootsRequest
CreateElicitationRequest
ElicitRequest
CustomRequest
}
}
@ -175,6 +175,7 @@ variant_extension! {
ProgressNotification
InitializedNotification
RootsListChangedNotification
TaskStatusNotification
CustomNotification
}
}
@ -188,7 +189,8 @@ variant_extension! {
ResourceListChangedNotification
ToolListChangedNotification
PromptListChangedNotification
ElicitationCompletionNotification
ElicitationCompleteNotification
TaskStatusNotification
CustomNotification
}
}

View file

@ -1,37 +1,31 @@
use serde::{Deserialize, Serialize};
use super::{
AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource,
content::{AudioContent, EmbeddedResource, ImageContent},
Annotations, ContentBlock, Icon, Meta, Role,
content::{AudioContent, EmbeddedResource, ImageContent, TextContent},
resource::ResourceContents,
};
/// A prompt that can be used to generate text from a model
/// A prompt or prompt template that the server offers (spec `Prompt`).
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Prompt {
/// The name of the prompt
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional description of what the prompt does
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Optional arguments that can be passed to customize the prompt
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<PromptArgument>>,
/// Optional list of icons for the prompt
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
/// Optional additional metadata for this prompt
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
}
impl Prompt {
/// Create a new prompt with the given name, description and arguments
pub fn new<N, D>(
name: N,
description: Option<D>,
@ -51,7 +45,6 @@ impl Prompt {
}
}
/// Create a new prompt from raw fields (used by the macro)
pub fn from_raw(
name: impl Into<String>,
description: Option<impl Into<String>>,
@ -67,45 +60,37 @@ impl Prompt {
}
}
/// Set the human-readable title
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Set the icons
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
/// Set the metadata
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
}
/// Represents a prompt argument that can be passed to customize the prompt
/// Describes an argument that a prompt can accept (spec `PromptArgument`).
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct PromptArgument {
/// The name of the argument
pub name: String,
/// A human-readable title for the argument
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// A description of what the argument is used for
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Whether this argument is required
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
}
impl PromptArgument {
/// Create a new prompt argument
pub fn new<N: Into<String>>(name: N) -> Self {
PromptArgument {
name: name.into(),
@ -115,108 +100,51 @@ impl PromptArgument {
}
}
/// Set the title
pub fn with_title<T: Into<String>>(mut self, title: T) -> Self {
self.title = Some(title.into());
self
}
/// Set the description
pub fn with_description<D: Into<String>>(mut self, description: D) -> Self {
self.description = Some(description.into());
self
}
/// Set the required flag
pub fn with_required(mut self, required: bool) -> Self {
self.required = Some(required);
self
}
}
/// Represents the role of a message sender in a prompt conversation
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum PromptMessageRole {
User,
Assistant,
}
/// Content types that can be included in prompt messages
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum PromptMessageContent {
/// Plain text content
Text { text: String },
/// Image content with base64-encoded data
Image {
#[serde(flatten)]
image: ImageContent,
},
/// Audio content with base64-encoded data
Audio {
#[serde(flatten)]
audio: AudioContent,
},
/// Embedded server-side resource
Resource {
#[serde(flatten)]
resource: EmbeddedResource,
},
/// A link to a resource that can be fetched separately
ResourceLink {
#[serde(flatten)]
link: super::resource::Resource,
},
}
impl PromptMessageContent {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
/// Create a resource link content
pub fn resource_link(resource: super::resource::Resource) -> Self {
Self::ResourceLink { link: resource }
}
}
/// A message in a prompt conversation
/// A message returned as part of a prompt (spec `PromptMessage`).
///
/// Uses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct PromptMessage {
/// The role of the message sender
pub role: PromptMessageRole,
/// The content of the message
pub content: PromptMessageContent,
pub role: Role,
pub content: ContentBlock,
}
impl PromptMessage {
/// Create a new prompt message with the given role and content
pub fn new(role: PromptMessageRole, content: PromptMessageContent) -> Self {
pub fn new(role: Role, content: ContentBlock) -> Self {
Self { role, content }
}
/// Create a new text message with the given role and text content
pub fn new_text<S: Into<String>>(role: PromptMessageRole, text: S) -> Self {
pub fn new_text<S: Into<String>>(role: Role, text: S) -> Self {
Self {
role,
content: PromptMessageContent::Text { text: text.into() },
content: ContentBlock::text(text),
}
}
/// Create a new image message. `meta` and `annotations` are optional.
#[cfg(feature = "base64")]
pub fn new_image(
role: PromptMessageRole,
role: Role,
data: &[u8],
mime_type: &str,
meta: Option<crate::model::Meta>,
meta: Option<Meta>,
annotations: Option<Annotations>,
) -> Self {
use base64::{Engine, prelude::BASE64_STANDARD};
@ -224,23 +152,21 @@ impl PromptMessage {
let base64 = BASE64_STANDARD.encode(data);
Self {
role,
content: PromptMessageContent::Image {
image: crate::model::RawImageContent {
data: base64,
mime_type: mime_type.into(),
meta,
}
.optional_annotate(annotations),
},
content: ContentBlock::Image(ImageContent {
data: base64,
mime_type: mime_type.into(),
meta,
annotations,
}),
}
}
/// Create a new audio message. `annotations` is optional.
#[cfg(feature = "base64")]
pub fn new_audio(
role: PromptMessageRole,
role: Role,
data: &[u8],
mime_type: &str,
meta: Option<Meta>,
annotations: Option<Annotations>,
) -> Self {
use base64::{Engine, prelude::BASE64_STANDARD};
@ -248,24 +174,22 @@ impl PromptMessage {
let base64 = BASE64_STANDARD.encode(data);
Self {
role,
content: PromptMessageContent::Audio {
audio: crate::model::RawAudioContent {
data: base64,
mime_type: mime_type.into(),
}
.optional_annotate(annotations),
},
content: ContentBlock::Audio(AudioContent {
data: base64,
mime_type: mime_type.into(),
meta,
annotations,
}),
}
}
/// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional.
pub fn new_resource(
role: PromptMessageRole,
role: Role,
uri: String,
mime_type: Option<String>,
text: Option<String>,
resource_meta: Option<crate::model::Meta>,
resource_content_meta: Option<crate::model::Meta>,
resource_meta: Option<Meta>,
resource_content_meta: Option<Meta>,
annotations: Option<Annotations>,
) -> Self {
let resource_contents = match text {
@ -284,31 +208,29 @@ impl PromptMessage {
};
Self {
role,
content: PromptMessageContent::Resource {
resource: RawEmbeddedResource {
meta: resource_meta,
resource: resource_contents,
}
.optional_annotate(annotations),
},
content: ContentBlock::Resource(EmbeddedResource {
meta: resource_meta,
resource: resource_contents,
annotations,
}),
}
}
/// Note: PromptMessage text content does not carry protocol-level _meta per current schema.
/// This function exists for API symmetry but ignores the meta parameter.
pub fn new_text_with_meta<S: Into<String>>(
role: PromptMessageRole,
text: S,
_meta: Option<crate::model::Meta>,
) -> Self {
Self::new_text(role, text)
}
/// Create a new resource link message
pub fn new_resource_link(role: PromptMessageRole, resource: super::resource::Resource) -> Self {
pub fn new_text_with_meta<S: Into<String>>(role: Role, text: S, meta: Option<Meta>) -> Self {
Self {
role,
content: PromptMessageContent::ResourceLink { link: resource },
content: ContentBlock::Text(TextContent {
text: text.into(),
meta,
annotations: None,
}),
}
}
pub fn new_resource_link(role: Role, resource: super::resource::Resource) -> Self {
Self {
role,
content: ContentBlock::ResourceLink(resource),
}
}
}
@ -321,35 +243,15 @@ mod tests {
#[test]
fn test_prompt_message_image_serialization() {
let image_content = crate::model::RawImageContent {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
meta: None,
};
let json = serde_json::to_string(&image_content).unwrap();
println!("PromptMessage ImageContent JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
let image = ImageContent::new("base64data", "image/png");
let json = serde_json::to_string(&image).unwrap();
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_prompt_message_audio_serialization_and_deserialization() {
// Audio is part of the spec's ContentBlock union for prompt messages
// (text | image | audio | resource_link | resource). Ensure the Audio
// variant serializes to the flat, spec-compliant shape
// `{ "type": "audio", "data", "mimeType" }` and parses back.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
let content = PromptMessageContent::Audio {
audio: crate::model::RawAudioContent {
data: "YXVkaW8=".to_string(),
mime_type: "audio/wav".to_string(),
}
.no_annotation(),
};
let content = ContentBlock::Audio(AudioContent::new("YXVkaW8=", "audio/wav"));
let value = serde_json::to_value(&content).unwrap();
assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio"));
assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8="));
@ -359,18 +261,15 @@ mod tests {
"expected camelCase mimeType, got: {value:#?}"
);
// Regression: a spec-valid audio content block must deserialize into
// the Audio variant (previously failed with "unknown variant `audio`").
let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#;
let parsed: PromptMessageContent = serde_json::from_str(json).unwrap();
let parsed: ContentBlock = serde_json::from_str(json).unwrap();
assert_eq!(parsed, content);
}
#[test]
#[cfg(feature = "base64")]
fn test_prompt_message_new_audio_constructor() {
let message =
PromptMessage::new_audio(PromptMessageRole::User, b"hello", "audio/wav", None);
let message = PromptMessage::new_audio(Role::User, b"hello", "audio/wav", None, None);
let value = serde_json::to_value(&message).unwrap();
let content = value.get("content").expect("content present");
assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio"));
@ -378,7 +277,6 @@ mod tests {
content.get("mimeType").and_then(|v| v.as_str()),
Some("audio/wav")
);
// base64 of "hello"
assert_eq!(
content.get("data").and_then(|v| v.as_str()),
Some("aGVsbG8=")
@ -387,16 +285,12 @@ mod tests {
#[test]
fn test_prompt_message_resource_link_serialization() {
use super::super::resource::RawResource;
use super::super::resource::Resource;
let resource = RawResource::new("file:///test.txt", "test.txt");
let message =
PromptMessage::new_resource_link(PromptMessageRole::User, resource.no_annotation());
let resource = Resource::new("file:///test.txt", "test.txt");
let message = PromptMessage::new_resource_link(Role::User, resource);
let json = serde_json::to_string(&message).unwrap();
println!("PromptMessage with ResourceLink JSON: {}", json);
// Verify it contains the correct type tag
assert!(json.contains("\"type\":\"resource_link\""));
assert!(json.contains("\"uri\":\"file:///test.txt\""));
assert!(json.contains("\"name\":\"test.txt\""));
@ -404,12 +298,8 @@ mod tests {
#[test]
fn test_prompt_message_resource_serialization_is_flat() {
// Regression test: PromptMessageContent::Resource must serialize to
// the spec-compliant flat shape `{ "type": "resource", "resource": { "uri", "mimeType", "text" } }`
// and NOT the double-nested shape `{ "type": "resource", "resource": { "resource": {...} } }`.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
let message = PromptMessage::new_resource(
PromptMessageRole::User,
Role::User,
"alc://packages/sc/narrative".to_string(),
Some("text/markdown".to_string()),
Some("# Hello".to_string()),
@ -419,8 +309,6 @@ mod tests {
);
let value: serde_json::Value = serde_json::to_value(&message).unwrap();
// Drill into content
let content = value.get("content").expect("content present");
assert_eq!(
content.get("type").and_then(|v| v.as_str()),
@ -431,7 +319,6 @@ mod tests {
.get("resource")
.expect("resource field present at content level");
// Spec-compliant: resource.uri / resource.mimeType / resource.text MUST be flat
assert_eq!(
resource.get("uri").and_then(|v| v.as_str()),
Some("alc://packages/sc/narrative"),
@ -446,7 +333,6 @@ mod tests {
Some("# Hello")
);
// Regression guard: content.resource MUST NOT contain a nested `resource` key.
assert!(
resource.get("resource").is_none(),
"double-nested resource detected (regression): {resource:#?}"
@ -463,13 +349,13 @@ mod tests {
"mimeType": "text/plain"
}"#;
let content: PromptMessageContent = serde_json::from_str(json).unwrap();
let content: ContentBlock = serde_json::from_str(json).unwrap();
if let PromptMessageContent::ResourceLink { link } = content {
assert_eq!(link.uri, "file:///example.txt");
assert_eq!(link.name, "example.txt");
assert_eq!(link.description, Some("Example file".to_string()));
assert_eq!(link.mime_type, Some("text/plain".to_string()));
if let ContentBlock::ResourceLink(resource) = content {
assert_eq!(resource.uri, "file:///example.txt");
assert_eq!(resource.name, "example.txt");
assert_eq!(resource.description, Some("Example file".to_string()));
assert_eq!(resource.mime_type, Some("text/plain".to_string()));
} else {
panic!("Expected ResourceLink variant");
}

View file

@ -1,66 +1,173 @@
use serde::{Deserialize, Serialize};
use super::{Annotated, Icon, Meta};
use super::{Annotations, Icon, Meta};
/// Represents a resource in the extension with metadata
/// A known resource that the server is capable of reading (spec `Resource`).
///
/// Also used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawResource {
/// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content")
#[non_exhaustive]
pub struct Resource {
/// The URI of this resource (e.g. `file:///path/to/file`).
pub uri: String,
/// Name of the resource
/// The programmatic name of the resource.
pub name: String,
/// Human-readable title of the resource
/// Optional human-readable display title.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional description of the resource
/// Optional description of what this resource represents.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// MIME type of the resource content ("text" or "blob")
/// The MIME type of this resource, if known.
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.
///
/// This can be used by Hosts to display file sizes and estimate context window us
/// The size of the raw resource content in bytes (before base64/tokenization), if known.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u32>,
/// Optional list of icons for the resource
pub size: Option<u64>,
/// Optional set of icons the client may display for this resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
/// Optional additional metadata for this resource
/// Optional protocol-level metadata for this resource.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub type Resource = Annotated<RawResource>;
impl Resource {
pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri: uri.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
size: None,
icons: None,
meta: None,
annotations: 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_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// A template description for resources available on the server (spec `ResourceTemplate`).
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawResourceTemplate {
#[non_exhaustive]
pub struct ResourceTemplate {
/// An RFC 6570 URI template for constructing resource URIs.
pub uri_template: String,
/// The programmatic name of the resource template.
pub name: String,
/// Optional human-readable display title.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional description of what this template is for.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The MIME type for resources matching this template, if uniform.
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// Optional list of icons for the resource template
/// Optional set of icons the client may display for this template.
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
/// Optional protocol-level metadata for this resource template.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this template.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub type ResourceTemplate = Annotated<RawResourceTemplate>;
impl ResourceTemplate {
pub fn new(uri_template: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri_template: uri_template.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
icons: None,
meta: None,
annotations: 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<Icon>) -> Self {
self.icons = Some(icons);
self
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// The contents of a specific resource or sub-resource.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum ResourceContents {
#[serde(rename_all = "camelCase")]
TextResourceContents {
@ -83,7 +190,6 @@ pub enum ResourceContents {
}
impl ResourceContents {
/// Create text resource contents.
pub fn text(text: impl Into<String>, uri: impl Into<String>) -> Self {
Self::TextResourceContents {
uri: uri.into(),
@ -93,7 +199,6 @@ impl ResourceContents {
}
}
/// Create blob resource contents.
pub fn blob(blob: impl Into<String>, uri: impl Into<String>) -> Self {
Self::BlobResourceContents {
uri: uri.into(),
@ -103,7 +208,6 @@ impl ResourceContents {
}
}
/// Set the MIME type on this resource contents.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
match &mut self {
Self::TextResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()),
@ -112,7 +216,6 @@ impl ResourceContents {
self
}
/// Set the metadata on this resource contents.
pub fn with_meta(mut self, meta: Meta) -> Self {
match &mut self {
Self::TextResourceContents { meta: m, .. } => *m = Some(meta),
@ -122,96 +225,6 @@ impl ResourceContents {
}
}
impl RawResource {
/// Creates a new Resource from a URI with explicit mime type
pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri: uri.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
size: None,
icons: None,
meta: None,
}
}
/// Set the human-readable title.
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Set the description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set the MIME type.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
/// Set the size in bytes.
pub fn with_size(mut self, size: u32) -> Self {
self.size = Some(size);
self
}
/// Set the icons.
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
/// Set the metadata.
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
}
impl RawResourceTemplate {
/// Creates a new RawResourceTemplate with a URI template and name.
pub fn new(uri_template: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri_template: uri_template.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
icons: None,
}
}
/// Set the human-readable title.
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Set the description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set the MIME type.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
/// Set the icons.
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
}
#[cfg(test)]
mod tests {
use serde_json;
@ -221,21 +234,12 @@ mod tests {
#[test]
fn test_resource_serialization() {
let resource = RawResource {
uri: "file:///test.txt".to_string(),
title: None,
name: "test".to_string(),
description: Some("Test resource".to_string()),
mime_type: Some("text/plain".to_string()),
size: Some(100),
icons: None,
meta: None,
};
let resource = Resource::new("file:///test.txt", "test")
.with_description("Test resource")
.with_mime_type("text/plain")
.with_size(100);
let json = serde_json::to_string(&resource).unwrap();
println!("Serialized JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
@ -250,28 +254,22 @@ mod tests {
};
let json = serde_json::to_string(&text_contents).unwrap();
println!("ResourceContents JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_resource_template_with_icons() {
let resource_template = RawResourceTemplate {
uri_template: "file:///{path}".to_string(),
name: "template".to_string(),
title: Some("Test Template".to_string()),
description: Some("A test resource template".to_string()),
mime_type: Some("text/plain".to_string()),
icons: Some(vec![Icon {
let resource_template = ResourceTemplate::new("file:///{path}", "template")
.with_title("Test Template")
.with_description("A test resource template")
.with_mime_type("text/plain")
.with_icons(vec![Icon {
src: "https://example.com/icon.png".to_string(),
mime_type: Some("image/png".to_string()),
sizes: Some(vec!["48x48".to_string()]),
theme: Some(IconTheme::Light),
}]),
};
}]);
let json = serde_json::to_value(&resource_template).unwrap();
assert!(json["icons"].is_array());
@ -282,16 +280,31 @@ mod tests {
#[test]
fn test_resource_template_without_icons() {
let resource_template = RawResourceTemplate {
uri_template: "file:///{path}".to_string(),
name: "template".to_string(),
title: None,
description: None,
mime_type: None,
icons: None,
};
let resource_template = ResourceTemplate::new("file:///{path}", "template");
let json = serde_json::to_value(&resource_template).unwrap();
assert!(json.get("icons").is_none());
}
#[test]
fn test_resource_size_u64() {
let resource = Resource::new("file:///big", "big").with_size(5_000_000_000);
let json = serde_json::to_value(&resource).unwrap();
assert_eq!(json["size"], 5_000_000_000_u64);
}
#[test]
fn test_resource_with_annotations() {
let resource = Resource::new("file:///test.txt", "test")
.with_annotations(Annotations::default().with_priority(0.9));
let json = serde_json::to_value(&resource).unwrap();
assert_eq!(json["annotations"]["priority"], 0.9_f32);
}
#[test]
fn test_resource_template_with_meta() {
let resource_template =
ResourceTemplate::new("file:///{path}", "template").with_meta(Meta::default());
let json = serde_json::to_value(&resource_template).unwrap();
assert!(json.get("_meta").is_some());
}
}

View file

@ -3,11 +3,54 @@ use serde_json::Value;
use super::Meta;
/// Metadata for augmenting a request with task execution (spec `TaskMetadata`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct TaskMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<u64>,
}
impl TaskMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
}
/// Metadata for associating messages with a task (spec `RelatedTaskMetadata`).
///
/// Carried in `_meta` under the key `"io.modelcontextprotocol/related-task"`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct RelatedTaskMetadata {
pub task_id: String,
}
impl RelatedTaskMetadata {
pub fn new(task_id: impl Into<String>) -> Self {
Self {
task_id: task_id.into(),
}
}
/// The well-known `_meta` key for related-task metadata.
pub const META_KEY: &str = "io.modelcontextprotocol/related-task";
}
/// Canonical task lifecycle status as defined by SEP-1686.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum TaskStatus {
/// The receiver accepted the request and is currently working on it.
#[default]
@ -95,12 +138,20 @@ impl Task {
#[non_exhaustive]
pub struct CreateTaskResult {
pub task: Task,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
}
impl CreateTaskResult {
/// Create a new CreateTaskResult.
pub fn new(task: Task) -> Self {
Self { task }
Self { task, meta: None }
}
/// Sets the protocol-level metadata for this result.
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
}
@ -111,7 +162,7 @@ impl CreateTaskResult {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct GetTaskResult {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
@ -119,6 +170,12 @@ pub struct GetTaskResult {
pub task: Task,
}
impl GetTaskResult {
pub fn new(task: Task) -> Self {
Self { meta: None, task }
}
}
/// Response to a `tasks/result` request.
///
/// Per spec, the result structure matches the original request type
@ -162,7 +219,7 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct CancelTaskResult {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
@ -170,26 +227,8 @@ pub struct CancelTaskResult {
pub task: Task,
}
/// Paginated list of tasks
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct TaskList {
pub tasks: Vec<Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
}
impl TaskList {
/// Create a new TaskList.
pub fn new(tasks: Vec<Task>) -> Self {
Self {
tasks,
next_cursor: None,
total: None,
}
impl CancelTaskResult {
pub fn new(task: Task) -> Self {
Self { meta: None, task }
}
}