rust-sdk/crates/rmcp/src/model/elicitation_schema.rs

2280 lines
72 KiB
Rust

//! Type-safe schema definitions for MCP elicitation requests.
//!
//! This module provides strongly-typed schema definitions for elicitation requests
//! that comply with the MCP 2025-06-18 specification. Elicitation schemas must be
//! objects with primitive-typed properties.
//!
//! # Example
//!
//! ```rust
//! use rmcp::model::*;
//!
//! let schema = ElicitationSchema::builder()
//! .required_email("email")
//! .required_integer("age", 0, 150)
//! .optional_bool("newsletter", false)
//! .build();
//! ```
use std::{borrow::Cow, collections::BTreeMap, marker::PhantomData};
use serde::{Deserialize, Serialize};
use crate::{const_string, model::ConstString};
// =============================================================================
// CONST TYPES FOR JSON SCHEMA TYPE FIELD
// =============================================================================
const_string!(ObjectTypeConst = "object");
const_string!(StringTypeConst = "string");
const_string!(NumberTypeConst = "number");
const_string!(IntegerTypeConst = "integer");
const_string!(BooleanTypeConst = "boolean");
const_string!(EnumTypeConst = "string");
const_string!(ArrayTypeConst = "array");
// =============================================================================
// PRIMITIVE SCHEMA DEFINITIONS
// =============================================================================
/// Primitive schema definition for elicitation properties.
///
/// According to MCP 2025-06-18 specification, elicitation schemas must have
/// properties of primitive types only (string, number, integer, boolean, enum).
///
/// Note: Put Enum as the first variant to avoid ambiguity during deserialization.
/// This is due to the fact that EnumSchema can contain StringSchema internally and serde
/// uses first match wins strategy when deserializing untagged enums.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[non_exhaustive]
pub enum PrimitiveSchemaDefinition {
/// Enum property (explicit enum schema)
Enum(EnumSchema),
/// String property (with optional enum constraint)
String(StringSchema),
/// Number property (with optional enum constraint)
Number(NumberSchema),
/// Integer property (with optional enum constraint)
Integer(IntegerSchema),
/// Boolean property
Boolean(BooleanSchema),
}
#[deprecated(since = "2.0.0", note = "Renamed to PrimitiveSchemaDefinition")]
pub type PrimitiveSchema = PrimitiveSchemaDefinition;
// =============================================================================
// STRING SCHEMA
// =============================================================================
/// String format types allowed by the MCP specification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum StringFormat {
/// Email address format
Email,
/// URI format
Uri,
/// Date format (YYYY-MM-DD)
Date,
/// Date-time format (ISO 8601)
DateTime,
}
/// Schema definition for string properties.
///
/// Compliant with MCP 2025-06-18 specification for elicitation schemas.
/// Supports only the fields allowed by the MCP spec:
/// - format limited to: "email", "uri", "date", "date-time"
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct StringSchema {
/// Type discriminator
#[serde(rename = "type")]
pub type_: StringTypeConst,
/// Optional title for the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
/// Human-readable description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
/// Minimum string length
#[serde(skip_serializing_if = "Option::is_none")]
pub min_length: Option<u32>,
/// Maximum string length
#[serde(skip_serializing_if = "Option::is_none")]
pub max_length: Option<u32>,
/// String format - limited to: "email", "uri", "date", "date-time"
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<StringFormat>,
/// Default value
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
impl Default for StringSchema {
fn default() -> Self {
Self {
type_: StringTypeConst,
title: None,
description: None,
min_length: None,
max_length: None,
format: None,
default: None,
}
}
}
impl StringSchema {
/// Create a new string schema
pub fn new() -> Self {
Self::default()
}
/// Create an email string schema
pub fn email() -> Self {
Self {
format: Some(StringFormat::Email),
..Default::default()
}
}
/// Create a URI string schema
pub fn uri() -> Self {
Self {
format: Some(StringFormat::Uri),
..Default::default()
}
}
/// Create a date string schema
pub fn date() -> Self {
Self {
format: Some(StringFormat::Date),
..Default::default()
}
}
/// Create a date-time string schema
pub fn date_time() -> Self {
Self {
format: Some(StringFormat::DateTime),
..Default::default()
}
}
/// Set title
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set description
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Set minimum and maximum length
pub fn with_length(mut self, min: u32, max: u32) -> Result<Self, &'static str> {
if min > max {
return Err("min_length must be <= max_length");
}
self.min_length = Some(min);
self.max_length = Some(max);
Ok(self)
}
/// Set minimum and maximum length (panics on invalid input)
pub fn length(mut self, min: u32, max: u32) -> Self {
assert!(min <= max, "min_length must be <= max_length");
self.min_length = Some(min);
self.max_length = Some(max);
self
}
/// Set minimum length
pub fn min_length(mut self, min: u32) -> Self {
self.min_length = Some(min);
self
}
/// Set maximum length
pub fn max_length(mut self, max: u32) -> Self {
self.max_length = Some(max);
self
}
/// Set format (limited to: "email", "uri", "date", "date-time")
pub fn format(mut self, format: StringFormat) -> Self {
self.format = Some(format);
self
}
/// Set default value
pub fn with_default(mut self, default: impl Into<String>) -> Self {
self.default = Some(default.into());
self
}
}
// =============================================================================
// NUMBER SCHEMA
// =============================================================================
/// Schema definition for number properties (floating-point).
///
/// Compliant with MCP 2025-06-18 specification for elicitation schemas.
/// Supports only the fields allowed by the MCP spec.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct NumberSchema {
/// Type discriminator
#[serde(rename = "type")]
pub type_: NumberTypeConst,
/// Optional title for the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
/// Human-readable description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
/// Minimum value (inclusive)
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<f64>,
/// Maximum value (inclusive)
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<f64>,
/// Default value
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<f64>,
}
impl Default for NumberSchema {
fn default() -> Self {
Self {
type_: NumberTypeConst,
title: None,
description: None,
minimum: None,
maximum: None,
default: None,
}
}
}
impl NumberSchema {
/// Create a new number schema
pub fn new() -> Self {
Self::default()
}
/// Set minimum and maximum (inclusive)
pub fn with_range(mut self, min: f64, max: f64) -> Result<Self, &'static str> {
if min > max {
return Err("minimum must be <= maximum");
}
self.minimum = Some(min);
self.maximum = Some(max);
Ok(self)
}
/// Set minimum and maximum (panics on invalid input)
pub fn range(mut self, min: f64, max: f64) -> Self {
assert!(min <= max, "minimum must be <= maximum");
self.minimum = Some(min);
self.maximum = Some(max);
self
}
/// Set minimum (inclusive)
pub fn minimum(mut self, min: f64) -> Self {
self.minimum = Some(min);
self
}
/// Set maximum (inclusive)
pub fn maximum(mut self, max: f64) -> Self {
self.maximum = Some(max);
self
}
/// Set title
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set description
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Set default value
pub fn with_default(mut self, default: f64) -> Self {
self.default = Some(default);
self
}
}
// =============================================================================
// INTEGER SCHEMA
// =============================================================================
/// Schema definition for integer properties.
///
/// Compliant with MCP 2025-06-18 specification for elicitation schemas.
/// Supports only the fields allowed by the MCP spec.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IntegerSchema {
/// Type discriminator
#[serde(rename = "type")]
pub type_: IntegerTypeConst,
/// Optional title for the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
/// Human-readable description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
/// Minimum value (inclusive)
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<i64>,
/// Maximum value (inclusive)
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<i64>,
/// Default value
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<i64>,
}
impl Default for IntegerSchema {
fn default() -> Self {
Self {
type_: IntegerTypeConst,
title: None,
description: None,
minimum: None,
maximum: None,
default: None,
}
}
}
impl IntegerSchema {
/// Create a new integer schema
pub fn new() -> Self {
Self::default()
}
/// Set minimum and maximum (inclusive)
pub fn with_range(mut self, min: i64, max: i64) -> Result<Self, &'static str> {
if min > max {
return Err("minimum must be <= maximum");
}
self.minimum = Some(min);
self.maximum = Some(max);
Ok(self)
}
/// Set minimum and maximum (panics on invalid input)
pub fn range(mut self, min: i64, max: i64) -> Self {
assert!(min <= max, "minimum must be <= maximum");
self.minimum = Some(min);
self.maximum = Some(max);
self
}
/// Set minimum (inclusive)
pub fn minimum(mut self, min: i64) -> Self {
self.minimum = Some(min);
self
}
/// Set maximum (inclusive)
pub fn maximum(mut self, max: i64) -> Self {
self.maximum = Some(max);
self
}
/// Set title
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set description
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Set default value
pub fn with_default(mut self, default: i64) -> Self {
self.default = Some(default);
self
}
}
// =============================================================================
// BOOLEAN SCHEMA
// =============================================================================
/// Schema definition for boolean properties.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct BooleanSchema {
/// Type discriminator
#[serde(rename = "type")]
pub type_: BooleanTypeConst,
/// Optional title for the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
/// Human-readable description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
/// Default value
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<bool>,
}
impl Default for BooleanSchema {
fn default() -> Self {
Self {
type_: BooleanTypeConst,
title: None,
description: None,
default: None,
}
}
}
impl BooleanSchema {
/// Create a new boolean schema
pub fn new() -> Self {
Self::default()
}
/// Set title
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set description
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Set default value
pub fn with_default(mut self, default: bool) -> Self {
self.default = Some(default);
self
}
}
// =============================================================================
// ENUM SCHEMA
// =============================================================================
/// Schema definition for enum properties.
///
/// Represent single entry for titled item
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ConstTitle {
#[serde(rename = "const")]
pub const_: String,
pub title: String,
}
impl ConstTitle {
/// Create a new ConstTitle.
pub fn new(const_: impl Into<String>, title: impl Into<String>) -> Self {
Self {
const_: const_.into(),
title: title.into(),
}
}
}
/// Legacy enum schema, keep for backward compatibility
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct LegacyEnumSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(rename = "enum")]
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
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct UntitledSingleSelectEnumSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(rename = "enum")]
pub enum_: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
/// Titled single-select
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct TitledSingleSelectEnumSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(rename = "oneOf")]
pub one_of: Vec<ConstTitle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
impl TitledSingleSelectEnumSchema {
/// Create a new TitledSingleSelectEnumSchema.
pub fn new(one_of: Vec<ConstTitle>) -> Self {
Self {
type_: StringTypeConst,
title: None,
description: None,
one_of,
default: None,
}
}
}
/// Combined single-select
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[non_exhaustive]
pub enum SingleSelectEnumSchema {
Untitled(UntitledSingleSelectEnumSchema),
Titled(TitledSingleSelectEnumSchema),
}
/// Items for untitled multi-select options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct UntitledItems {
#[serde(rename = "type")]
pub type_: StringTypeConst,
#[serde(rename = "enum")]
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))]
#[non_exhaustive]
pub struct TitledItems {
// MCP spec requires "anyOf" for multi-select enums (allows any combination)
// Alias "oneOf" for compatibility with schemars
#[serde(rename = "anyOf", alias = "oneOf")]
pub any_of: Vec<ConstTitle>,
}
impl TitledItems {
/// Create a new TitledItems.
pub fn new(any_of: Vec<ConstTitle>) -> Self {
Self { any_of }
}
}
/// Multi-select untitled options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct UntitledMultiSelectEnumSchema {
#[serde(rename = "type")]
pub type_: ArrayTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_items: Option<u64>,
pub items: UntitledItems,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
}
/// Multi-select titled options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct TitledMultiSelectEnumSchema {
#[serde(rename = "type")]
pub type_: ArrayTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_items: Option<u64>,
pub items: TitledItems,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
}
impl TitledMultiSelectEnumSchema {
/// Create a new TitledMultiSelectEnumSchema.
pub fn new(items: TitledItems) -> Self {
Self {
type_: ArrayTypeConst,
title: None,
description: None,
min_items: None,
max_items: None,
items,
default: None,
}
}
/// Set the title.
pub fn with_title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set the description.
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Set the minimum number of items.
pub fn with_min_items(mut self, min_items: u64) -> Self {
self.min_items = Some(min_items);
self
}
/// Set the maximum number of items.
pub fn with_max_items(mut self, max_items: u64) -> Self {
self.max_items = Some(max_items);
self
}
/// Set the default values.
pub fn with_default(mut self, default: Vec<String>) -> Self {
self.default = Some(default);
self
}
}
/// Multi-select enum options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[non_exhaustive]
pub enum MultiSelectEnumSchema {
Untitled(UntitledMultiSelectEnumSchema),
Titled(TitledMultiSelectEnumSchema),
}
/// Compliant with MCP 2025-06-18 specification for elicitation schemas.
/// Enums must have string type for values and can optionally include human-readable names.
///
/// # Example
///
/// ```rust
/// use rmcp::model::*;
///
/// let enum_schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])
/// .multiselect()
/// .min_items(1u64).expect("Min items should be correct value")
/// .max_items(4u64).expect("Max items should be correct value")
/// .description("Country code")
/// .build();
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[non_exhaustive]
pub enum EnumSchema {
Single(SingleSelectEnumSchema),
Multi(MultiSelectEnumSchema),
Legacy(LegacyEnumSchema),
}
/// Marker type for single-select enum builder
#[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct SingleSelect;
/// Marker type for multi-select enum builder
#[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct MultiSelect;
/// Builder for EnumSchema
/// Allows to create various enum schema types (single/multi select, titled/untitled)
/// with validation of provided parameters
///
/// # Example
/// ```rust
/// use rmcp::model::*;
/// let enum_schema = EnumSchema::builder(vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()])
/// .multiselect()
/// .enum_titles(vec!["Red Color".to_string(), "Green Color".to_string(), "Blue Color".to_string()])
/// .expect("Number of titles should match number of values")
/// .min_items(1u64).expect("Min items should be correct value")
/// .max_items(3u64).expect("Max items should be correct value")
/// .description("Select your favorite colors")
/// .build();
/// ```
#[derive(Debug)]
pub struct EnumSchemaBuilder<T> {
/// Enum values
enum_values: Vec<String>,
/// If true generate Titled EnumSchema, UnTitled otherwise
titled: bool,
/// Title of EnumSchema
title: Option<Cow<'static, str>>,
/// Description of EnumSchema
description: Option<Cow<'static, str>>,
/// Titles of given enum values
enum_titles: Vec<String>,
/// Minimum number of items to choose for MultiSelect
min_items: Option<u64>,
/// Maximum number of items to choose for MultiSelect
max_items: Option<u64>,
/// Default values for enum
default: Vec<String>,
select_type: PhantomData<T>,
}
/// Default implementation for single-select enum builder
impl Default for EnumSchemaBuilder<SingleSelect> {
fn default() -> Self {
Self {
title: None,
description: None,
titled: false,
enum_titles: Vec::new(),
enum_values: Vec::new(),
min_items: None,
max_items: None,
default: Vec::new(),
select_type: PhantomData,
}
}
}
/// Common enum schema builder methods
impl<T> EnumSchemaBuilder<T> {
/// Set title of enum schema
pub fn title(mut self, value: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(value.into());
self
}
/// Set description of enum schema
pub fn description(mut self, value: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(value.into());
self
}
/// Set enum as untitled
/// Clears any previously set titles
pub fn untitled(mut self) -> Self {
self.enum_titles = Vec::new();
self.titled = false;
self
}
/// Set titles to enum values. Also, implicitly set this enum schema as titled
pub fn enum_titles(mut self, titles: Vec<String>) -> Result<EnumSchemaBuilder<T>, String> {
if titles.len() != self.enum_values.len() {
return Err(format!(
"Provided number of titles do not match number of values: expected {}, but got {}",
self.enum_values.len(),
titles.len()
));
}
self.titled = true;
self.enum_titles = titles;
Ok(self)
}
}
/// Enum selection builder for single-select enums
impl EnumSchemaBuilder<SingleSelect> {
pub fn new(values: Vec<String>) -> EnumSchemaBuilder<SingleSelect> {
EnumSchemaBuilder {
enum_values: values,
..Default::default()
}
}
/// Transition to multi-select enum builder.
///
/// Clears any previously set default values and resets min/max items.
/// After this transition, you can use `min_items()`, `max_items()`, and
/// `with_default()` for multi-select semantics.
pub fn multiselect(self) -> EnumSchemaBuilder<MultiSelect> {
EnumSchemaBuilder {
enum_values: self.enum_values,
titled: self.titled,
title: self.title,
description: self.description,
enum_titles: self.enum_titles,
min_items: None,
max_items: None,
default: Vec::new(), // Clear default for multi-select
select_type: PhantomData,
}
}
/// Set default value
pub fn with_default(
mut self,
default_value: impl Into<String>,
) -> Result<EnumSchemaBuilder<SingleSelect>, String> {
let value: String = default_value.into();
if !self.enum_values.contains(&value) {
return Err("Provided default value is not in enum values".to_string());
}
self.default = vec![value];
Ok(self)
}
/// Build enum schema
pub fn build(mut self) -> EnumSchema {
match self.titled {
false => EnumSchema::Single(SingleSelectEnumSchema::Untitled(
UntitledSingleSelectEnumSchema {
type_: StringTypeConst,
title: self.title,
description: self.description,
enum_: self.enum_values,
default: self.default.pop(),
},
)),
true => EnumSchema::Single(SingleSelectEnumSchema::Titled(
TitledSingleSelectEnumSchema {
type_: StringTypeConst,
title: self.title,
description: self.description,
one_of: self
.enum_titles
.into_iter()
.zip(self.enum_values)
.map(|(title, const_)| ConstTitle { const_, title })
.collect(),
default: self.default.pop(),
},
)),
}
}
}
/// Enum selection builder for multi-select enums
impl EnumSchemaBuilder<MultiSelect> {
/// Set enum as single-select
/// If it was multi-select, clear default values
pub fn single_select(self) -> EnumSchemaBuilder<SingleSelect> {
EnumSchemaBuilder {
enum_values: self.enum_values,
titled: self.titled,
title: self.title,
description: self.description,
enum_titles: self.enum_titles,
min_items: None,
max_items: None,
default: Vec::new(), // Clear default for single-select
select_type: PhantomData,
}
}
/// Set default values
pub fn with_default(
mut self,
default_values: Vec<String>,
) -> Result<EnumSchemaBuilder<MultiSelect>, String> {
for value in &default_values {
if !self.enum_values.contains(value) {
return Err("One of the provided default values is not in enum values".to_string());
}
}
if let Some(min) = self.min_items {
if (default_values.len() as u64) < min {
return Err("Number of provided default values is less than min_items".to_string());
}
}
if let Some(max) = self.max_items {
if (default_values.len() as u64) > max {
return Err(
"Number of provided default values is greater than max_items".to_string(),
);
}
}
self.default = default_values;
Ok(self)
}
/// Set minimal number of items for multi-select enum options
pub fn min_items(mut self, value: u64) -> Result<EnumSchemaBuilder<MultiSelect>, String> {
if let Some(max) = self.max_items
&& value > max
{
return Err("Provided value is greater than max_items".to_string());
}
self.min_items = Some(value);
Ok(self)
}
/// Set maximal number of items for multi-select enum options
pub fn max_items(mut self, value: u64) -> Result<EnumSchemaBuilder<MultiSelect>, String> {
if let Some(min) = self.min_items
&& value < min
{
return Err("Provided value is less than min_items".to_string());
}
self.max_items = Some(value);
Ok(self)
}
/// Build enum schema
pub fn build(self) -> EnumSchema {
match self.titled {
false => EnumSchema::Multi(MultiSelectEnumSchema::Untitled(
UntitledMultiSelectEnumSchema {
type_: ArrayTypeConst,
title: self.title,
description: self.description,
min_items: self.min_items,
max_items: self.max_items,
items: UntitledItems {
type_: StringTypeConst,
enum_: self.enum_values,
},
default: if self.default.is_empty() {
None
} else {
Some(self.default)
},
},
)),
true => EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema {
type_: ArrayTypeConst,
title: self.title,
description: self.description,
min_items: self.min_items,
max_items: self.max_items,
items: TitledItems {
any_of: self
.enum_titles
.into_iter()
.zip(self.enum_values)
.map(|(title, const_)| ConstTitle { const_, title })
.collect(),
},
default: if self.default.is_empty() {
None
} else {
Some(self.default)
},
})),
}
}
}
impl EnumSchema {
/// Creates a new `EnumSchemaBuilder` with the given enum values.
///
/// This convenience method allows you to construct an enum schema by specifying
/// the possible string values for the enum. Use the returned builder to further
/// configure the schema before building it.
///
/// # Arguments
///
/// * `values` - A vector of strings representing the allowed enum values.
///
/// # Example
///
/// ```
/// use rmcp::model::*;
///
/// let enum_schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]).
/// with_default("A").
/// expect("Default value should be valid").
/// enum_titles(vec!["Option A".to_string(), "Option B".to_string()]).
/// expect("Number of titles should match number of values").
/// build();
/// ```
pub fn builder(values: Vec<String>) -> EnumSchemaBuilder<SingleSelect> {
EnumSchemaBuilder::new(values)
}
}
// =============================================================================
// ELICITATION SCHEMA
// =============================================================================
/// Type-safe elicitation schema for requesting structured user input.
///
/// This enforces the MCP 2025-06-18 specification that elicitation schemas
/// must be objects with primitive-typed properties.
///
/// # Example
///
/// ```rust
/// use rmcp::model::*;
///
/// let schema = ElicitationSchema::builder()
/// .required_email("email")
/// .required_integer("age", 0, 150)
/// .optional_bool("newsletter", false)
/// .build();
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ElicitationSchema {
/// Always "object" for elicitation schemas
#[serde(rename = "type")]
pub type_: ObjectTypeConst,
/// Optional title for the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
/// Property definitions (must be primitive types)
pub properties: BTreeMap<String, PrimitiveSchemaDefinition>,
/// List of required property names
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
/// Optional description of what this schema represents
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
}
impl ElicitationSchema {
/// Create a new elicitation schema with the given properties
pub fn new(properties: BTreeMap<String, PrimitiveSchemaDefinition>) -> Self {
Self {
type_: ObjectTypeConst,
title: None,
properties,
required: None,
description: None,
}
}
/// Convert from a JSON Schema object (typically generated by schemars)
///
/// This allows converting from JsonObject to ElicitationSchema, which is useful
/// when working with automatically generated schemas from types.
///
/// # Example
///
/// ```rust,ignore
/// use rmcp::model::*;
///
/// let json_schema = schema_for_type::<MyType>();
/// let elicitation_schema = ElicitationSchema::from_json_schema(json_schema)?;
/// ```
///
/// # Errors
///
/// Returns a [`serde_json::Error`] if the JSON object cannot be deserialized
/// into a valid ElicitationSchema.
pub fn from_json_schema(schema: crate::model::JsonObject) -> Result<Self, serde_json::Error> {
serde_json::from_value(serde_json::Value::Object(schema))
}
/// Generate an ElicitationSchema from a Rust type that implements JsonSchema
///
/// This is a convenience method that combines schema generation and conversion.
/// It uses the same schema generation settings as the rest of the MCP SDK.
///
/// # Example
///
/// ```rust,ignore
/// use rmcp::model::*;
/// use schemars::JsonSchema;
/// use serde::{Deserialize, Serialize};
///
/// #[derive(JsonSchema, Serialize, Deserialize)]
/// struct UserInput {
/// name: String,
/// age: u32,
/// }
///
/// let schema = ElicitationSchema::from_type::<UserInput>()?;
/// ```
///
/// # Errors
///
/// Returns a [`serde_json::Error`] if the generated schema cannot be converted
/// to a valid ElicitationSchema.
#[cfg(feature = "schemars")]
pub fn from_type<T>() -> Result<Self, serde_json::Error>
where
T: schemars::JsonSchema,
{
use crate::schemars::generate::SchemaSettings;
let mut settings = SchemaSettings::draft07();
settings.transforms = vec![Box::new(schemars::transform::AddNullable::default())];
let generator = settings.into_generator();
let schema = generator.into_root_schema_for::<T>();
let object = serde_json::to_value(schema).expect("failed to serialize schema");
match object {
serde_json::Value::Object(object) => Self::from_json_schema(object),
_ => panic!(
"Schema serialization produced non-object value: expected JSON object but got {:?}",
object
),
}
}
/// Set the required fields
pub fn with_required(mut self, required: Vec<String>) -> Self {
self.required = Some(required);
self
}
/// Set the title
pub fn with_title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set the description
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Create a builder for constructing elicitation schemas fluently
pub fn builder() -> ElicitationSchemaBuilder {
ElicitationSchemaBuilder::new()
}
}
// =============================================================================
// BUILDER
// =============================================================================
/// Fluent builder for constructing elicitation schemas.
///
/// # Example
///
/// ```rust
/// use rmcp::model::*;
///
/// let schema = ElicitationSchema::builder()
/// .required_email("email")
/// .required_integer("age", 0, 150)
/// .optional_bool("newsletter", false)
/// .description("User registration")
/// .build();
/// ```
#[derive(Debug, Default)]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct ElicitationSchemaBuilder {
pub properties: BTreeMap<String, PrimitiveSchemaDefinition>,
pub required: Vec<String>,
pub title: Option<Cow<'static, str>>,
pub description: Option<Cow<'static, str>>,
}
impl ElicitationSchemaBuilder {
/// Create a new builder
pub fn new() -> Self {
Self::default()
}
/// Add a property to the schema
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: PrimitiveSchemaDefinition,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(name_str, schema);
self
}
// ===========================================================================
// TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchemaDefinition wrapper
// ===========================================================================
/// Add a string property with custom builder (required)
pub fn string_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
);
self
}
/// Add a required string property with custom builder
pub fn required_string_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
);
self
}
/// Add a number property with custom builder
pub fn number_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
);
self
}
/// Add a required number property with custom builder
pub fn required_number_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
);
self
}
/// Add an integer property with custom builder
pub fn integer_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
);
self
}
/// Add a required integer property with custom builder
pub fn required_integer_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
);
self
}
/// Add a boolean property with custom builder
pub fn bool_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
);
self
}
/// Add a required boolean property with custom builder
pub fn required_bool_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
);
self
}
// ===========================================================================
// CONVENIENCE METHODS - Simple common cases
// ===========================================================================
/// Add a required string property
pub fn required_string(self, name: impl Into<String>) -> Self {
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, PrimitiveSchemaDefinition::String(StringSchema::new()))
}
/// Add a required email property
pub fn required_email(self, name: impl Into<String>) -> Self {
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,
PrimitiveSchemaDefinition::String(StringSchema::email()),
)
}
/// Add a required string property with custom builder
pub fn required_string_with(
self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
)
}
/// Add an optional string property with custom builder
pub fn optional_string_with(
self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.property(
name,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
)
}
// Convenience methods for numbers
/// Add a required number property with range
pub fn required_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)),
)
}
/// Add an optional number property with range
pub fn optional_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.property(
name,
PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)),
)
}
/// Add a required number property with custom builder
pub fn required_number_with(
self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
)
}
/// Add an optional number property with custom builder
pub fn optional_number_with(
self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.property(
name,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
)
}
// Convenience methods for integers
/// Add a required integer property with range
pub fn required_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)),
)
}
/// Add an optional integer property with range
pub fn optional_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.property(
name,
PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)),
)
}
/// Add a required integer property with custom builder
pub fn required_integer_with(
self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
)
}
/// Add an optional integer property with custom builder
pub fn optional_integer_with(
self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
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,
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,
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new().with_default(default)),
)
}
/// Add a required boolean property with custom builder
pub fn required_bool_with(
self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
)
}
/// Add an optional boolean property with custom builder
pub fn optional_bool_with(
self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
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, 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, PrimitiveSchemaDefinition::Enum(enum_schema))
}
/// Add a required enum property using values. Creates an untitled single-select enum.
#[deprecated(
since = "0.13.0",
note = "Use ElicitationSchemaBuilder::required_enum_schema with EnumSchema::builder instead"
)]
pub fn required_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.required_property(
name,
PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: values,
enum_names: None,
default: None,
})),
)
}
/// Add an optional enum property using values. Creates an untitled single-select enum.
#[deprecated(
since = "0.13.0",
note = "Use ElicitationSchemaBuilder::optional_enum_schema with EnumSchema::builder instead"
)]
pub fn optional_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.property(
name,
PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: values,
enum_names: None,
default: None,
})),
)
}
/// Mark an existing property as required
pub fn mark_required(mut self, name: impl Into<String>) -> Self {
self.required.push(name.into());
self
}
/// Set the schema title
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
/// Set the schema description
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
/// Build the elicitation schema with validation
///
/// # Errors
///
/// Returns an error if:
/// - Required fields reference non-existent properties
/// - No properties are defined (empty schema)
pub fn build(self) -> Result<ElicitationSchema, &'static str> {
// Validate that all required fields exist in properties
if !self.required.is_empty() {
for field_name in &self.required {
if !self.properties.contains_key(field_name) {
return Err("Required field does not exist in properties");
}
}
}
Ok(ElicitationSchema {
type_: ObjectTypeConst,
title: self.title,
properties: self.properties,
required: if self.required.is_empty() {
None
} else {
Some(self.required)
},
description: self.description,
})
}
/// Build the elicitation schema without validation (panics on invalid schema)
///
/// # Panics
///
/// Panics if required fields reference non-existent properties
pub fn build_unchecked(self) -> ElicitationSchema {
self.build().expect("Invalid elicitation schema")
}
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use rstest::rstest;
use serde_json::json;
use super::*;
fn string_schema_json() -> serde_json::Value {
serde_json::to_value(StringSchema::email().description("Email address")).unwrap()
}
fn number_schema_json() -> serde_json::Value {
serde_json::to_value(
NumberSchema::new()
.range(0.0, 100.0)
.description("Percentage"),
)
.unwrap()
}
fn integer_schema_json() -> serde_json::Value {
serde_json::to_value(IntegerSchema::new().range(0, 150)).unwrap()
}
fn boolean_schema_json() -> serde_json::Value {
serde_json::to_value(BooleanSchema::new().with_default(true)).unwrap()
}
#[rstest]
#[case::string_schema(
string_schema_json,
json!({
"type": "string",
"format": "email",
"description": "Email address",
})
)]
#[case::number_schema(
number_schema_json,
json!({
"type": "number",
"description": "Percentage",
"minimum": 0.0,
"maximum": 100.0,
})
)]
#[case::integer_schema(
integer_schema_json,
json!({
"type": "integer",
"minimum": 0,
"maximum": 150,
})
)]
#[case::boolean_schema(
boolean_schema_json,
json!({
"type": "boolean",
"default": true,
})
)]
fn primitive_schema_serializes_to_expected_json(
#[case] schema_json: fn() -> serde_json::Value,
#[case] expected: serde_json::Value,
) {
assert_eq!(schema_json(), expected);
}
#[test]
fn test_enum_schema_untitled_single_select_serialization() {
let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])
.description("Country code")
.build();
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "string");
assert_eq!(json["enum"], json!(["US", "UK"]));
assert_eq!(json["description"], "Country code");
}
#[test]
fn test_enum_schema_untitled_multi_select_serialization() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])
.multiselect()
.min_items(1u64)
.map_err(|e| anyhow!("{e}"))?
.max_items(4u64)
.map_err(|e| anyhow!("{e}"))?
.description("Country code")
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "array");
assert_eq!(json["minItems"], 1u64);
assert_eq!(json["maxItems"], 4u64);
assert_eq!(json["items"], json!({"type":"string", "enum":["US", "UK"]}));
assert_eq!(json["description"], "Country code");
Ok(())
}
#[test]
fn test_enum_schema_titled_single_select_serialization() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])
.enum_titles(vec![
"United States".to_string(),
"United Kingdom".to_string(),
])
.map_err(|e| anyhow!("{e}"))?
.description("Country code")
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "string");
assert_eq!(
json["oneOf"],
json!([
{"const": "US", "title":"United States"},
{"const": "UK", "title":"United Kingdom"}
])
);
assert_eq!(json["description"], "Country code");
Ok(())
}
#[test]
fn test_enum_schema_legacy_serialization() -> anyhow::Result<()> {
let schema = EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: Some("Legacy Enum".into()),
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)?;
assert_eq!(json["type"], "string");
assert_eq!(json["title"], "Legacy Enum");
assert_eq!(json["description"], "A legacy enum schema");
assert_eq!(json["enum"], json!(["A", "B"]));
assert_eq!(json["enumNames"], json!(["Option A", "Option B"]));
Ok(())
}
#[test]
fn test_legacy_enum_schema_roundtrip_preserves_enum_names() -> anyhow::Result<()> {
// Regression test for: legacy enum payload with `enumNames` was silently
// deserialized as `UntitledSingleSelectEnumSchema` (which has no `enumNames`
// field), causing the array to be dropped on re-serialization.
let input = serde_json::json!({
"type": "object",
"properties": {
"choice": {
"type": "string",
"enum": ["opt1", "opt2", "opt3"],
"enumNames": ["Option One", "Option Two", "Option Three"]
}
}
});
let schema: ElicitationSchema = serde_json::from_value(input.clone())?;
let output = serde_json::to_value(&schema)?;
assert_eq!(
output["properties"]["choice"]["enumNames"],
serde_json::json!(["Option One", "Option Two", "Option Three"]),
);
Ok(())
}
#[test]
fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> {
// `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`.
let schema = EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
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"));
Ok(())
}
#[test]
fn test_enum_schema_titled_multi_select_serialization() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])
.enum_titles(vec![
"United States".to_string(),
"United Kingdom".to_string(),
])
.map_err(|e| anyhow!("{e}"))?
.multiselect()
.min_items(1u64)
.map_err(|e| anyhow!("{e}"))?
.max_items(4u64)
.map_err(|e| anyhow!("{e}"))?
.description("Country code")
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "array");
assert_eq!(json["minItems"], 1u64);
assert_eq!(json["maxItems"], 4u64);
assert_eq!(
json["items"],
json!({"anyOf":[
{"const":"US","title":"United States"},
{"const":"UK","title":"United Kingdom"}
]})
);
assert_eq!(json["description"], "Country code");
Ok(())
}
#[test]
fn test_enum_schema_single_select_with_default() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec![
"red".to_string(),
"green".to_string(),
"blue".to_string(),
])
.with_default("green")
.map_err(|e| anyhow!("{e}"))?
.description("Favorite color")
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "string");
assert_eq!(json["enum"], json!(["red", "green", "blue"]));
assert_eq!(json["default"], "green");
assert_eq!(json["description"], "Favorite color");
Ok(())
}
#[test]
fn test_enum_schema_multi_select_with_default() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec![
"red".to_string(),
"green".to_string(),
"blue".to_string(),
])
.multiselect()
.with_default(vec!["red".to_string(), "blue".to_string()])
.map_err(|e| anyhow!("{e}"))?
.min_items(1)
.map_err(|e| anyhow!("{e}"))?
.max_items(3)
.map_err(|e| anyhow!("{e}"))?
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "array");
assert_eq!(json["items"]["enum"], json!(["red", "green", "blue"]));
assert_eq!(json["default"], json!(["red", "blue"]));
assert_eq!(json["minItems"], 1);
assert_eq!(json["maxItems"], 3);
Ok(())
}
#[test]
fn test_enum_schema_transition_clears_defaults() -> anyhow::Result<()> {
// Start with single-select with default
let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string()])
.with_default("A")
.map_err(|e| anyhow!("{e}"))?;
// Transition to multi-select should clear the default
let schema = builder.multiselect().build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "array");
assert!(json["default"].is_null());
Ok(())
}
#[test]
fn test_enum_schema_multi_to_single_transition() -> anyhow::Result<()> {
// Start with multi-select with defaults
let builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string(), "C".to_string()])
.multiselect()
.with_default(vec!["A".to_string(), "B".to_string()])
.map_err(|e| anyhow!("{e}"))?
.min_items(1)
.map_err(|e| anyhow!("{e}"))?;
// Transition back to single-select should clear defaults and min/max items
let schema = builder.single_select().build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "string");
assert!(json["default"].is_null());
assert!(json["minItems"].is_null());
assert!(json["maxItems"].is_null());
Ok(())
}
#[test]
fn test_enum_schema_invalid_single_default() {
let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]).with_default("C");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Provided default value is not in enum values"
);
}
#[test]
fn test_enum_schema_invalid_multi_default() {
let result = EnumSchema::builder(vec!["A".to_string(), "B".to_string()])
.multiselect()
.with_default(vec!["A".to_string(), "C".to_string()]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"One of the provided default values is not in enum values"
);
}
#[test]
fn test_enum_schema_titled_with_default() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])
.enum_titles(vec![
"United States".to_string(),
"United Kingdom".to_string(),
])
.map_err(|e| anyhow!("{e}"))?
.with_default("UK")
.map_err(|e| anyhow!("{e}"))?
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "string");
assert_eq!(json["default"], "UK");
assert_eq!(
json["oneOf"],
json!([
{"const": "US", "title": "United States"},
{"const": "UK", "title": "United Kingdom"}
])
);
Ok(())
}
#[test]
fn test_enum_schema_untitled_after_titled() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec!["A".to_string(), "B".to_string()])
.enum_titles(vec!["Option A".to_string(), "Option B".to_string()])
.map_err(|e| anyhow!("{e}"))?
.untitled()
.build();
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "string");
assert_eq!(json["enum"], json!(["A", "B"]));
assert!(json["oneOf"].is_null());
Ok(())
}
#[test]
fn test_primitive_schema_enum_deserialization() {
// Test that enum schemas deserialize as Enum variant, not String
let json = json!({
"type": "string",
"enum": ["a", "b"]
});
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: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchemaDefinition::String(_)));
}
#[test]
fn test_elicitation_schema_builder_simple() {
let schema = ElicitationSchema::builder()
.required_email("email")
.optional_bool("newsletter", false)
.build()
.unwrap();
assert_eq!(schema.properties.len(), 2);
assert!(schema.properties.contains_key("email"));
assert!(schema.properties.contains_key("newsletter"));
assert_eq!(schema.required, Some(vec!["email".to_string()]));
}
#[test]
fn test_elicitation_schema_builder_complex() {
let enum_schema =
EnumSchema::builder(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]).build();
let schema = ElicitationSchema::builder()
.required_string_with("name", |s| s.length(1, 100))
.required_integer("age", 0, 150)
.optional_bool("newsletter", false)
.required_enum_schema("country", enum_schema)
.description("User registration")
.build()
.unwrap();
assert_eq!(schema.properties.len(), 4);
assert_eq!(
schema.required,
Some(vec![
"name".to_string(),
"age".to_string(),
"country".to_string()
])
);
assert_eq!(schema.description.as_deref(), Some("User registration"));
}
#[test]
fn test_elicitation_schema_serialization() {
let schema = ElicitationSchema::builder()
.required_string_with("name", |s| s.length(1, 100))
.build()
.unwrap();
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "object");
assert!(json["properties"]["name"].is_object());
assert_eq!(json["required"], json!(["name"]));
}
#[test]
#[should_panic(expected = "minimum must be <= maximum")]
fn test_integer_range_validation() {
IntegerSchema::new().range(10, 5); // Should panic
}
#[test]
#[should_panic(expected = "min_length must be <= max_length")]
fn test_string_length_validation() {
StringSchema::new().length(10, 5); // Should panic
}
#[test]
fn test_integer_range_validation_with_result() {
let result = IntegerSchema::new().with_range(10, 5);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "minimum must be <= maximum");
}
#[cfg(feature = "schemars")]
mod schemars_tests {
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::model::ElicitationSchema;
#[derive(Debug, Serialize, Deserialize, JsonSchema, Default)]
#[schemars(inline)]
#[schemars(extend("type" = "string"))]
enum TitledEnum {
#[schemars(title = "Title for the first value")]
#[default]
FirstValue,
#[schemars(title = "Title for the second value")]
SecondValue,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(inline)]
enum UntitledEnum {
First,
Second,
Third,
}
fn default_untitled_multi_select() -> Vec<UntitledEnum> {
vec![UntitledEnum::Second, UntitledEnum::Third]
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(description = "User information")]
struct UserInfo {
#[schemars(description = "User's name")]
pub name: String,
pub single_select_untitled: UntitledEnum,
#[schemars(
title = "Single Select Titled",
description = "Description for single select enum",
default
)]
pub single_select_titled: TitledEnum,
#[serde(default = "default_untitled_multi_select")]
pub multi_select_untitled: Vec<UntitledEnum>,
#[schemars(
title = "Multi Select Titled",
description = "Multi Select Description"
)]
pub multi_select_titled: Vec<TitledEnum>,
}
#[test]
fn test_schema_inference_for_enum_fields() -> Result<()> {
let schema = ElicitationSchema::from_type::<UserInfo>()?;
let json = serde_json::to_value(&schema)?;
assert_eq!(json["type"], "object");
assert_eq!(json["description"], "User information");
assert_eq!(
json["required"],
json!(["name", "single_select_untitled", "multi_select_titled"])
);
let properties = match json.get("properties") {
Some(serde_json::Value::Object(map)) => map,
_ => panic!("Schema does not have 'properties' field or it is not object type"),
};
assert_eq!(properties.len(), 5);
assert_eq!(
properties["name"],
json!({"description":"User's name", "type":"string"})
);
assert_eq!(
properties["single_select_untitled"],
serde_json::json!({
"type":"string",
"enum":["First", "Second", "Third"]
})
);
assert_eq!(
properties["single_select_titled"],
json!({
"type":"string",
"title":"Single Select Titled",
"description":"Description for single select enum",
"oneOf":[
{"const":"FirstValue", "title":"Title for the first value"},
{"const":"SecondValue", "title":"Title for the second value"}
],
"default":"FirstValue"
})
);
assert_eq!(
properties["multi_select_untitled"],
serde_json::json!({
"type":"array",
"items" : {
"type":"string",
"enum":["First", "Second", "Third"]
},
"default":["Second", "Third"]
})
);
assert_eq!(
properties["multi_select_titled"],
serde_json::json!({
"type":"array",
"title":"Multi Select Titled",
"description":"Multi Select Description",
"items":{
"anyOf":[
{"const":"FirstValue", "title":"Title for the first value"},
{"const":"SecondValue", "title":"Title for the second value"}
]
}
})
);
Ok(())
}
}
}