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:
parent
42a1069833
commit
77932141e3
8 changed files with 4321 additions and 3886 deletions
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue