diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index b7007052..fb757c09 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2,6 +2,7 @@ use std::{borrow::Cow, sync::Arc}; mod annotated; mod capabilities; mod content; +mod elicitation_schema; mod extension; mod meta; mod prompt; @@ -11,6 +12,7 @@ mod tool; pub use annotated::*; pub use capabilities::*; pub use content::*; +pub use elicitation_schema::*; pub use extension::*; pub use meta::*; pub use prompt::*; @@ -1377,7 +1379,21 @@ pub enum ElicitationAction { /// /// This structure contains everything needed to request interactive input from a user: /// - A human-readable message explaining what information is needed -/// - A JSON schema defining the expected structure of the response +/// - A type-safe schema defining the expected structure of the response +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::*; +/// +/// let params = CreateElicitationRequestParam { +/// message: "Please provide your email".to_string(), +/// requested_schema: ElicitationSchema::builder() +/// .required_email("email") +/// .build() +/// .unwrap(), +/// }; +/// ``` #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -1387,10 +1403,10 @@ pub struct CreateElicitationRequestParam { /// what information they need to provide. pub message: String, - /// JSON Schema defining the expected structure and validation rules for the user's response. - /// This allows clients to validate input and provide appropriate UI controls. - /// Must be a valid JSON Schema Draft 2020-12 object. - pub requested_schema: JsonObject, + /// Type-safe schema defining the expected structure and validation rules for the user's response. + /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects + /// with primitive-typed properties. + pub requested_schema: ElicitationSchema, } /// The result returned by a client in response to an elicitation request. diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs new file mode 100644 index 00000000..095e28e9 --- /dev/null +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -0,0 +1,1180 @@ +//! 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}; + +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"); + +// ============================================================================= +// 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). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum PrimitiveSchema { + /// 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), + /// Enum property (explicit enum schema) + Enum(EnumSchema), +} + +// ============================================================================= +// 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")] +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")] +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>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Minimum string length + #[serde(skip_serializing_if = "Option::is_none")] + pub min_length: Option, + + /// Maximum string length + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + + /// String format - limited to: "email", "uri", "date", "date-time" + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +impl Default for StringSchema { + fn default() -> Self { + Self { + type_: StringTypeConst, + title: None, + description: None, + min_length: None, + max_length: None, + format: 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>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Set minimum and maximum length + pub fn with_length(mut self, min: u32, max: u32) -> Result { + 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 + } +} + +// ============================================================================= +// 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")] +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>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Minimum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + + /// Maximum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, +} + +impl Default for NumberSchema { + fn default() -> Self { + Self { + type_: NumberTypeConst, + title: None, + description: None, + minimum: None, + maximum: 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 { + 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>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + 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")] +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>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Minimum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + + /// Maximum value (inclusive) + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, +} + +impl Default for IntegerSchema { + fn default() -> Self { + Self { + type_: IntegerTypeConst, + title: None, + description: None, + minimum: None, + maximum: 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 { + 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>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + 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")] +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>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +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>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> 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. +/// +/// Compliant with MCP 2025-06-18 specification for elicitation schemas. +/// Enums must have string type and can optionally include human-readable names. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct EnumSchema { + /// Type discriminator (always "string" for enums) + #[serde(rename = "type")] + pub type_: StringTypeConst, + + /// Allowed enum values (string values only per MCP spec) + #[serde(rename = "enum")] + pub enum_values: Vec, + + /// Optional human-readable names for each enum value + #[serde(skip_serializing_if = "Option::is_none")] + pub enum_names: Option>, + + /// Optional title for the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + + /// Human-readable description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, +} + +impl EnumSchema { + /// Create a new enum schema with string values + pub fn new(values: Vec) -> Self { + Self { + type_: StringTypeConst, + enum_values: values, + enum_names: None, + title: None, + description: None, + } + } + + /// Set enum names (human-readable names for each enum value) + pub fn enum_names(mut self, names: Vec) -> Self { + self.enum_names = Some(names); + self + } + + /// Set title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } +} + +// ============================================================================= +// 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")] +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>, + + /// Property definitions (must be primitive types) + pub properties: BTreeMap, + + /// List of required property names + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + + /// Optional description of what this schema represents + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, +} + +impl ElicitationSchema { + /// Create a new elicitation schema with the given properties + pub fn new(properties: BTreeMap) -> 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::(); + /// 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 { + 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::()?; + /// ``` + /// + /// # Errors + /// + /// Returns a [`serde_json::Error`] if the generated schema cannot be converted + /// to a valid ElicitationSchema. + #[cfg(feature = "schemars")] + pub fn from_type() -> Result + 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::(); + 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) -> Self { + self.required = Some(required); + self + } + + /// Set the title + pub fn with_title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description + pub fn with_description(mut self, description: impl Into>) -> 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)] +pub struct ElicitationSchemaBuilder { + pub properties: BTreeMap, + pub required: Vec, + pub title: Option>, + pub description: Option>, +} + +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, schema: PrimitiveSchema) -> Self { + self.properties.insert(name.into(), schema); + self + } + + /// Add a required property to the schema + pub fn required_property(mut self, name: impl Into, schema: PrimitiveSchema) -> 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 PrimitiveSchema wrapper + // =========================================================================== + + /// Add a string property with custom builder (required) + pub fn string_property( + mut self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + self.properties + .insert(name.into(), PrimitiveSchema::String(f(StringSchema::new()))); + self + } + + /// Add a required string property with custom builder + pub fn required_string_property( + mut self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties + .insert(name_str, PrimitiveSchema::String(f(StringSchema::new()))); + self + } + + /// Add a number property with custom builder + pub fn number_property( + mut self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + self.properties + .insert(name.into(), PrimitiveSchema::Number(f(NumberSchema::new()))); + self + } + + /// Add a required number property with custom builder + pub fn required_number_property( + mut self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties + .insert(name_str, PrimitiveSchema::Number(f(NumberSchema::new()))); + self + } + + /// Add an integer property with custom builder + pub fn integer_property( + mut self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + self.properties.insert( + name.into(), + PrimitiveSchema::Integer(f(IntegerSchema::new())), + ); + self + } + + /// Add a required integer property with custom builder + pub fn required_integer_property( + mut self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties + .insert(name_str, PrimitiveSchema::Integer(f(IntegerSchema::new()))); + self + } + + /// Add a boolean property with custom builder + pub fn bool_property( + mut self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + self.properties.insert( + name.into(), + PrimitiveSchema::Boolean(f(BooleanSchema::new())), + ); + self + } + + /// Add a required boolean property with custom builder + pub fn required_bool_property( + mut self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + let name_str = name.into(); + self.required.push(name_str.clone()); + self.properties + .insert(name_str, PrimitiveSchema::Boolean(f(BooleanSchema::new()))); + self + } + + // =========================================================================== + // CONVENIENCE METHODS - Simple common cases + // =========================================================================== + + /// Add a required string property + pub fn required_string(self, name: impl Into) -> Self { + self.required_property(name, PrimitiveSchema::String(StringSchema::new())) + } + + /// Add an optional string property + pub fn optional_string(self, name: impl Into) -> Self { + self.property(name, PrimitiveSchema::String(StringSchema::new())) + } + + /// Add a required email property + pub fn required_email(self, name: impl Into) -> Self { + self.required_property(name, PrimitiveSchema::String(StringSchema::email())) + } + + /// Add an optional email property + pub fn optional_email(self, name: impl Into) -> Self { + self.property(name, PrimitiveSchema::String(StringSchema::email())) + } + + /// Add a required string property with custom builder + pub fn required_string_with( + self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + self.required_property(name, PrimitiveSchema::String(f(StringSchema::new()))) + } + + /// Add an optional string property with custom builder + pub fn optional_string_with( + self, + name: impl Into, + f: impl FnOnce(StringSchema) -> StringSchema, + ) -> Self { + self.property(name, PrimitiveSchema::String(f(StringSchema::new()))) + } + + // Convenience methods for numbers + + /// Add a required number property with range + pub fn required_number(self, name: impl Into, min: f64, max: f64) -> Self { + self.required_property( + name, + PrimitiveSchema::Number(NumberSchema::new().range(min, max)), + ) + } + + /// Add an optional number property with range + pub fn optional_number(self, name: impl Into, min: f64, max: f64) -> Self { + self.property( + name, + PrimitiveSchema::Number(NumberSchema::new().range(min, max)), + ) + } + + /// Add a required number property with custom builder + pub fn required_number_with( + self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + self.required_property(name, PrimitiveSchema::Number(f(NumberSchema::new()))) + } + + /// Add an optional number property with custom builder + pub fn optional_number_with( + self, + name: impl Into, + f: impl FnOnce(NumberSchema) -> NumberSchema, + ) -> Self { + self.property(name, PrimitiveSchema::Number(f(NumberSchema::new()))) + } + + // Convenience methods for integers + + /// Add a required integer property with range + pub fn required_integer(self, name: impl Into, min: i64, max: i64) -> Self { + self.required_property( + name, + PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)), + ) + } + + /// Add an optional integer property with range + pub fn optional_integer(self, name: impl Into, min: i64, max: i64) -> Self { + self.property( + name, + PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)), + ) + } + + /// Add a required integer property with custom builder + pub fn required_integer_with( + self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + self.required_property(name, PrimitiveSchema::Integer(f(IntegerSchema::new()))) + } + + /// Add an optional integer property with custom builder + pub fn optional_integer_with( + self, + name: impl Into, + f: impl FnOnce(IntegerSchema) -> IntegerSchema, + ) -> Self { + self.property(name, PrimitiveSchema::Integer(f(IntegerSchema::new()))) + } + + // Convenience methods for booleans + + /// Add a required boolean property + pub fn required_bool(self, name: impl Into) -> Self { + self.required_property(name, PrimitiveSchema::Boolean(BooleanSchema::new())) + } + + /// Add an optional boolean property with default value + pub fn optional_bool(self, name: impl Into, default: bool) -> Self { + self.property( + name, + PrimitiveSchema::Boolean(BooleanSchema::new().with_default(default)), + ) + } + + /// Add a required boolean property with custom builder + pub fn required_bool_with( + self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + self.required_property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new()))) + } + + /// Add an optional boolean property with custom builder + pub fn optional_bool_with( + self, + name: impl Into, + f: impl FnOnce(BooleanSchema) -> BooleanSchema, + ) -> Self { + self.property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new()))) + } + + // Enum convenience methods + + /// Add a required enum property + pub fn required_enum(self, name: impl Into, values: Vec) -> Self { + self.required_property(name, PrimitiveSchema::Enum(EnumSchema::new(values))) + } + + /// Add an optional enum property + pub fn optional_enum(self, name: impl Into, values: Vec) -> Self { + self.property(name, PrimitiveSchema::Enum(EnumSchema::new(values))) + } + + /// Mark an existing property as required + pub fn mark_required(mut self, name: impl Into) -> Self { + self.required.push(name.into()); + self + } + + /// Set the schema title + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the schema description + pub fn description(mut self, description: impl Into>) -> 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 { + // 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 serde_json::json; + + use super::*; + + #[test] + fn test_string_schema_serialization() { + let schema = StringSchema::email().description("Email address"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert_eq!(json["format"], "email"); + assert_eq!(json["description"], "Email address"); + } + + #[test] + fn test_number_schema_serialization() { + let schema = NumberSchema::new() + .range(0.0, 100.0) + .description("Percentage"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "number"); + assert_eq!(json["minimum"], 0.0); + assert_eq!(json["maximum"], 100.0); + } + + #[test] + fn test_integer_schema_serialization() { + let schema = IntegerSchema::new().range(0, 150); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "integer"); + assert_eq!(json["minimum"], 0); + assert_eq!(json["maximum"], 150); + } + + #[test] + fn test_boolean_schema_serialization() { + let schema = BooleanSchema::new().with_default(true); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "boolean"); + assert_eq!(json["default"], true); + } + + #[test] + fn test_enum_schema_serialization() { + let schema = EnumSchema::new(vec!["US".to_string(), "UK".to_string()]) + .enum_names(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ]) + .description("Country code"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["US", "UK"])); + assert_eq!( + json["enumNames"], + json!(["United States", "United Kingdom"]) + ); + assert_eq!(json["description"], "Country code"); + } + + #[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 schema = ElicitationSchema::builder() + .required_string_with("name", |s| s.length(1, 100)) + .required_integer("age", 0, 150) + .optional_bool("newsletter", false) + .required_enum( + "country", + vec!["US".to_string(), "UK".to_string(), "CA".to_string()], + ) + .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"); + } +} diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 513196d3..82a7e7d8 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -677,7 +677,16 @@ impl Peer { } // Generate schema automatically from type - let schema = crate::handler::server::tool::schema_for_type::(); + let schema = crate::model::ElicitationSchema::from_type::().map_err(|e| { + ElicitationError::Service(ServiceError::McpError(crate::ErrorData::invalid_params( + format!( + "Invalid schema for type {}: {}", + std::any::type_name::(), + e + ), + None, + ))) + })?; let response = self .create_elicitation_with_timeout( diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 6b233e8b..8dc6f160 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -39,23 +39,14 @@ async fn test_elicitation_serialization() { /// Test CreateElicitationRequestParam structure serialization/deserialization #[tokio::test] async fn test_elicitation_request_param_serialization() { - let schema_object = json!({ - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": ["email"] - }) - .as_object() - .unwrap() - .clone(); + let schema = ElicitationSchema::builder() + .required_property("email", PrimitiveSchema::String(StringSchema::email())) + .build() + .unwrap(); let request_param = CreateElicitationRequestParam { message: "Please provide your email address".to_string(), - requested_schema: schema_object, + requested_schema: schema, }; // Test serialization @@ -123,16 +114,13 @@ async fn test_elicitation_result_serialization() { /// Test that elicitation requests can be created and handled through the JSON-RPC protocol #[tokio::test] async fn test_elicitation_json_rpc_protocol() { - let schema = json!({ - "type": "object", - "properties": { - "confirmation": {"type": "boolean"} - }, - "required": ["confirmation"] - }) - .as_object() - .unwrap() - .clone(); + let schema = ElicitationSchema::builder() + .required_property( + "confirmation", + PrimitiveSchema::Boolean(BooleanSchema::new()), + ) + .build() + .unwrap(); // Create a complete JSON-RPC request for elicitation let request = JsonRpcRequest { @@ -223,19 +211,22 @@ async fn test_elicitation_spec_compliance() { /// Test error handling and edge cases for elicitation #[tokio::test] async fn test_elicitation_error_handling() { - // Test invalid JSON schema handling - let invalid_schema_request = CreateElicitationRequestParam { + // Test minimal schema handling (empty properties is technically valid) + let minimal_schema_request = CreateElicitationRequestParam { message: "Test message".to_string(), - requested_schema: serde_json::Map::new(), // Empty schema is technically valid + requested_schema: ElicitationSchema::builder().build().unwrap(), }; // Should serialize without error - let _json = serde_json::to_value(&invalid_schema_request).unwrap(); + let _json = serde_json::to_value(&minimal_schema_request).unwrap(); // Test empty message let empty_message_request = CreateElicitationRequestParam { message: "".to_string(), - requested_schema: json!({"type": "string"}).as_object().unwrap().clone(), + requested_schema: ElicitationSchema::builder() + .property("value", PrimitiveSchema::String(StringSchema::new())) + .build() + .unwrap(), }; // Should serialize without error (validation is up to the implementation) @@ -250,15 +241,10 @@ async fn test_elicitation_error_handling() { /// Benchmark-style test for elicitation performance #[tokio::test] async fn test_elicitation_performance() { - let schema = json!({ - "type": "object", - "properties": { - "data": {"type": "string"} - } - }) - .as_object() - .unwrap() - .clone(); + let schema = ElicitationSchema::builder() + .property("data", PrimitiveSchema::String(StringSchema::new())) + .build() + .unwrap(); let request = CreateElicitationRequestParam { message: "Performance test message".to_string(), @@ -383,51 +369,52 @@ async fn test_elicitation_convenience_methods() { .contains("Option A") ); - // Test that CreateElicitationRequestParam can be created with these schemas + // Test that CreateElicitationRequestParam can be created with type-safe schemas let confirmation_request = CreateElicitationRequestParam { message: "Test confirmation".to_string(), - requested_schema: confirmation_schema.as_object().unwrap().clone(), + requested_schema: ElicitationSchema::builder() + .property( + "confirmed", + PrimitiveSchema::Boolean( + BooleanSchema::new() + .description("User confirmation (true for yes, false for no)"), + ), + ) + .build() + .unwrap(), }; // Test serialization of convenience method request let json = serde_json::to_value(&confirmation_request).unwrap(); assert_eq!(json["message"], "Test confirmation"); - assert_eq!(json["requestedSchema"]["type"], "boolean"); + assert_eq!(json["requestedSchema"]["type"], "object"); + assert_eq!( + json["requestedSchema"]["properties"]["confirmed"]["type"], + "boolean" + ); } -/// Test structured input with complex schemas -/// Ensures that complex JSON schemas work correctly with elicitation +/// Test structured input with multiple primitive properties +/// Ensures that schemas with multiple primitive properties work correctly with elicitation #[tokio::test] async fn test_elicitation_structured_schemas() { - // Test complex nested object schema - let complex_schema = json!({ - "type": "object", - "properties": { - "user": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string", "format": "email"}, - "preferences": { - "type": "object", - "properties": { - "theme": {"type": "string", "enum": ["light", "dark"]}, - "notifications": {"type": "boolean"} - } - } - } - }, - "metadata": { - "type": "array", - "items": {"type": "string"} - } - }, - "required": ["user"] - }); + // Test schema with multiple primitive properties + let schema = ElicitationSchema::builder() + .required_string_with("name", |s| s.length(1, 100)) + .required_email("email") + .required_integer("age", 0, 150) + .optional_bool("newsletter", false) + .required_enum( + "country", + vec!["US".to_string(), "UK".to_string(), "CA".to_string()], + ) + .description("User registration information") + .build() + .unwrap(); let request = CreateElicitationRequestParam { message: "Please provide your user information".to_string(), - requested_schema: complex_schema.as_object().unwrap().clone(), + requested_schema: schema, }; // Test that complex schemas serialize/deserialize correctly @@ -435,36 +422,41 @@ async fn test_elicitation_structured_schemas() { let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.message, "Please provide your user information"); + assert_eq!(deserialized.requested_schema.properties.len(), 5); + assert!( + deserialized + .requested_schema + .properties + .contains_key("name") + ); + assert!( + deserialized + .requested_schema + .properties + .contains_key("email") + ); + assert!(deserialized.requested_schema.properties.contains_key("age")); + assert!( + deserialized + .requested_schema + .properties + .contains_key("newsletter") + ); + assert!( + deserialized + .requested_schema + .properties + .contains_key("country") + ); assert_eq!( - deserialized.requested_schema["properties"]["user"]["properties"]["name"]["type"], - "string" + deserialized.requested_schema.required, + Some(vec![ + "name".to_string(), + "email".to_string(), + "age".to_string(), + "country".to_string() + ]) ); - - // Test array schema - let array_schema = json!({ - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "integer"}, - "name": {"type": "string"} - }, - "required": ["id", "name"] - }, - "minItems": 1, - "maxItems": 10 - }); - - let array_request = CreateElicitationRequestParam { - message: "Please provide a list of items".to_string(), - requested_schema: array_schema.as_object().unwrap().clone(), - }; - - // Verify array schema - let json = serde_json::to_value(&array_request).unwrap(); - assert_eq!(json["requestedSchema"]["type"], "array"); - assert_eq!(json["requestedSchema"]["minItems"], 1); - assert_eq!(json["requestedSchema"]["maxItems"], 10); } // Typed elicitation tests using the API with schemars @@ -649,13 +641,13 @@ async fn test_elicitation_direction_server_to_client() { use serde_json::json; // Test that server can create elicitation requests - let schema = json!({ - "type": "string", - "description": "Enter your name" - }) - .as_object() - .unwrap() - .clone(); + let schema = ElicitationSchema::builder() + .property( + "name", + PrimitiveSchema::String(StringSchema::new().description("Enter your name")), + ) + .build() + .unwrap(); let elicitation_request = CreateElicitationRequestParam { message: "Please enter your name".to_string(), @@ -665,7 +657,7 @@ async fn test_elicitation_direction_server_to_client() { // Verify request can be serialized let serialized = serde_json::to_value(&elicitation_request).unwrap(); assert_eq!(serialized["message"], "Please enter your name"); - assert_eq!(serialized["requestedSchema"]["type"], "string"); + assert_eq!(serialized["requestedSchema"]["type"], "object"); // Test that elicitation requests are part of ServerRequest let _server_request = ServerRequest::CreateElicitationRequest(CreateElicitationRequest { @@ -697,13 +689,13 @@ async fn test_elicitation_json_rpc_direction() { use rmcp::model::*; use serde_json::json; - let schema = json!({ - "type": "boolean", - "description": "Do you want to continue?" - }) - .as_object() - .unwrap() - .clone(); + let schema = ElicitationSchema::builder() + .property( + "continue", + PrimitiveSchema::Boolean(BooleanSchema::new().description("Do you want to continue?")), + ) + .build() + .unwrap(); // 1. Server creates elicitation request let server_request = ServerJsonRpcMessage::request( @@ -1143,17 +1135,11 @@ async fn test_create_elicitation_with_timeout_basic() { use std::time::Duration; // This test verifies that the method accepts timeout parameter - let schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - }, - "required": ["name", "email"] - }) - .as_object() - .unwrap() - .clone(); + let schema = ElicitationSchema::builder() + .required_property("name", PrimitiveSchema::String(StringSchema::new())) + .required_property("email", PrimitiveSchema::String(StringSchema::new())) + .build() + .unwrap(); let _params = CreateElicitationRequestParam { message: "Enter your details".to_string(), @@ -1473,3 +1459,146 @@ async fn test_elicitation_examples_compile() { _assert_safe::(); } } + +// ============================================================================= +// BUILD-TIME VALIDATION TESTS +// ============================================================================= + +/// Test that build() validates required fields exist in properties +#[tokio::test] +async fn test_build_validation_required_field_not_in_properties() { + // Try to mark a field as required that doesn't exist in properties + let result = ElicitationSchema::builder() + .property("email", PrimitiveSchema::String(StringSchema::email())) + .mark_required("nonexistent_field") + .build(); + + // Should return an error + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Required field does not exist in properties" + ); +} + +/// Test that build() succeeds when all required fields exist +#[tokio::test] +async fn test_build_validation_required_field_exists() { + let result = ElicitationSchema::builder() + .property("email", PrimitiveSchema::String(StringSchema::email())) + .property("name", PrimitiveSchema::String(StringSchema::new())) + .mark_required("email") + .mark_required("name") + .build(); + + // Should succeed + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 2); + assert_eq!( + schema.required, + Some(vec!["email".to_string(), "name".to_string()]) + ); +} + +/// Test that build_unchecked() panics on validation errors +#[tokio::test] +#[should_panic(expected = "Invalid elicitation schema")] +async fn test_build_unchecked_panics_on_invalid() { + // build_unchecked validates but panics instead of returning Result + let _schema = ElicitationSchema::builder() + .property("email", PrimitiveSchema::String(StringSchema::email())) + .mark_required("nonexistent_field") + .build_unchecked(); +} + +/// Test convenience methods handle validation correctly +#[tokio::test] +async fn test_convenience_methods_validation() { + // required_string_property should add both property and mark as required + let result = ElicitationSchema::builder() + .required_string_property("name", |s| s) + .required_email("email") + .build(); + + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 2); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string()) + ); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"email".to_string()) + ); +} + +/// Test typed property methods work correctly +#[tokio::test] +async fn test_typed_property_methods() { + let result = ElicitationSchema::builder() + .string_property("name", |s| s.length(1, 100)) + .number_property("price", |n| n.range(0.0, 1000.0)) + .integer_property("quantity", |i| i.range(1, 100)) + .bool_property("in_stock", |b| b.with_default(true)) + .build(); + + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 4); + + // Verify types are correct + if let Some(PrimitiveSchema::String(_)) = schema.properties.get("name") { + // Expected + } else { + panic!("name should be StringSchema"); + } + + if let Some(PrimitiveSchema::Number(_)) = schema.properties.get("price") { + // Expected + } else { + panic!("price should be NumberSchema"); + } + + if let Some(PrimitiveSchema::Integer(_)) = schema.properties.get("quantity") { + // Expected + } else { + panic!("quantity should be IntegerSchema"); + } + + if let Some(PrimitiveSchema::Boolean(_)) = schema.properties.get("in_stock") { + // Expected + } else { + panic!("in_stock should be BooleanSchema"); + } +} + +/// Test required typed property methods +#[tokio::test] +async fn test_required_typed_property_methods() { + let result = ElicitationSchema::builder() + .required_string_property("name", |s| s) + .required_number_property("price", |n| n) + .required_integer_property("age", |i| i) + .required_bool_property("active", |b| b) + .build(); + + assert!(result.is_ok()); + let schema = result.unwrap(); + assert_eq!(schema.properties.len(), 4); + assert_eq!(schema.required.as_ref().unwrap().len(), 4); + + // All should be marked as required + let required = schema.required.as_ref().unwrap(); + assert!(required.contains(&"name".to_string())); + assert!(required.contains(&"price".to_string())); + assert!(required.contains(&"age".to_string())); + assert!(required.contains(&"active".to_string())); +} diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 828e10b3..663a6894 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -307,6 +307,49 @@ } } }, + "BooleanSchema": { + "description": "Schema definition for boolean properties.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "boolean", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/BooleanTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "BooleanTypeConst": { + "type": "string", + "format": "const", + "const": "boolean" + }, "CallToolResult": { "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", @@ -423,7 +466,7 @@ ] }, "CreateElicitationRequestParam": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A JSON schema defining the expected structure of the response", + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", "type": "object", "properties": { "message": { @@ -431,9 +474,12 @@ "type": "string" }, "requestedSchema": { - "description": "JSON Schema defining the expected structure and validation rules for the user's response.\nThis allows clients to validate input and provide appropriate UI controls.\nMust be a valid JSON Schema Draft 2020-12 object.", - "type": "object", - "additionalProperties": true + "description": "Type-safe schema defining the expected structure and validation rules for the user's response.\nThis enforces the MCP 2025-06-18 specification that elicitation schemas must be objects\nwith primitive-typed properties.", + "allOf": [ + { + "$ref": "#/definitions/ElicitationSchema" + } + ] } }, "required": [ @@ -564,10 +610,108 @@ "format": "const", "const": "elicitation/create" }, + "ElicitationSchema": { + "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", + "type": "object", + "properties": { + "description": { + "description": "Optional description of what this schema represents", + "type": [ + "string", + "null" + ] + }, + "properties": { + "description": "Property definitions (must be primitive types)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PrimitiveSchema" + } + }, + "required": { + "description": "List of required property names", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Always \"object\" for elicitation schemas", + "allOf": [ + { + "$ref": "#/definitions/ObjectTypeConst" + } + ] + } + }, + "required": [ + "type", + "properties" + ] + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object" }, + "EnumSchema": { + "description": "Schema definition for enum properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type and can optionally include human-readable names.", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "enum": { + "description": "Allowed enum values (string values only per MCP spec)", + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "description": "Optional human-readable names for each enum value", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator (always \"string\" for enums)", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type", + "enum" + ] + }, "ErrorCode": { "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", "type": "integer", @@ -726,6 +870,58 @@ "serverInfo" ] }, + "IntegerSchema": { + "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/IntegerTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "IntegerTypeConst": { + "type": "string", + "format": "const", + "const": "integer" + }, "JsonRpcError": { "type": "object", "properties": { @@ -1120,11 +1316,113 @@ } ] }, + "NumberSchema": { + "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/NumberTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "NumberTypeConst": { + "type": "string", + "format": "const", + "const": "number" + }, + "ObjectTypeConst": { + "type": "string", + "format": "const", + "const": "object" + }, "PingRequestMethod": { "type": "string", "format": "const", "const": "ping" }, + "PrimitiveSchema": { + "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).", + "anyOf": [ + { + "description": "String property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/StringSchema" + } + ] + }, + { + "description": "Number property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/NumberSchema" + } + ] + }, + { + "description": "Integer property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/IntegerSchema" + } + ] + }, + { + "description": "Boolean property", + "allOf": [ + { + "$ref": "#/definitions/BooleanSchema" + } + ] + }, + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + } + ] + }, "ProgressNotificationMethod": { "type": "string", "format": "const", @@ -1900,6 +2198,96 @@ } ] }, + "StringFormat": { + "description": "String format types allowed by the MCP specification.", + "oneOf": [ + { + "description": "Email address format", + "type": "string", + "const": "email" + }, + { + "description": "URI format", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601)", + "type": "string", + "const": "date-time" + } + ] + }, + "StringSchema": { + "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "format": { + "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "anyOf": [ + { + "$ref": "#/definitions/StringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "description": "Maximum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "minLength": { + "description": "Minimum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "StringTypeConst": { + "type": "string", + "format": "const", + "const": "string" + }, "Tool": { "description": "A tool that can be used by a model.", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 828e10b3..663a6894 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -307,6 +307,49 @@ } } }, + "BooleanSchema": { + "description": "Schema definition for boolean properties.", + "type": "object", + "properties": { + "default": { + "description": "Default value", + "type": [ + "boolean", + "null" + ] + }, + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/BooleanTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "BooleanTypeConst": { + "type": "string", + "format": "const", + "const": "boolean" + }, "CallToolResult": { "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", @@ -423,7 +466,7 @@ ] }, "CreateElicitationRequestParam": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A JSON schema defining the expected structure of the response", + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", "type": "object", "properties": { "message": { @@ -431,9 +474,12 @@ "type": "string" }, "requestedSchema": { - "description": "JSON Schema defining the expected structure and validation rules for the user's response.\nThis allows clients to validate input and provide appropriate UI controls.\nMust be a valid JSON Schema Draft 2020-12 object.", - "type": "object", - "additionalProperties": true + "description": "Type-safe schema defining the expected structure and validation rules for the user's response.\nThis enforces the MCP 2025-06-18 specification that elicitation schemas must be objects\nwith primitive-typed properties.", + "allOf": [ + { + "$ref": "#/definitions/ElicitationSchema" + } + ] } }, "required": [ @@ -564,10 +610,108 @@ "format": "const", "const": "elicitation/create" }, + "ElicitationSchema": { + "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", + "type": "object", + "properties": { + "description": { + "description": "Optional description of what this schema represents", + "type": [ + "string", + "null" + ] + }, + "properties": { + "description": "Property definitions (must be primitive types)", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PrimitiveSchema" + } + }, + "required": { + "description": "List of required property names", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Always \"object\" for elicitation schemas", + "allOf": [ + { + "$ref": "#/definitions/ObjectTypeConst" + } + ] + } + }, + "required": [ + "type", + "properties" + ] + }, "EmptyObject": { "description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.", "type": "object" }, + "EnumSchema": { + "description": "Schema definition for enum properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nEnums must have string type and can optionally include human-readable names.", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "enum": { + "description": "Allowed enum values (string values only per MCP spec)", + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "description": "Optional human-readable names for each enum value", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator (always \"string\" for enums)", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type", + "enum" + ] + }, "ErrorCode": { "description": "Standard JSON-RPC error codes used throughout the MCP protocol.\n\nThese codes follow the JSON-RPC 2.0 specification and provide\nstandardized error reporting across all MCP implementations.", "type": "integer", @@ -726,6 +870,58 @@ "serverInfo" ] }, + "IntegerSchema": { + "description": "Schema definition for integer properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/IntegerTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "IntegerTypeConst": { + "type": "string", + "format": "const", + "const": "integer" + }, "JsonRpcError": { "type": "object", "properties": { @@ -1120,11 +1316,113 @@ } ] }, + "NumberSchema": { + "description": "Schema definition for number properties (floating-point).\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec.", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "maximum": { + "description": "Maximum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "minimum": { + "description": "Minimum value (inclusive)", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/NumberTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "NumberTypeConst": { + "type": "string", + "format": "const", + "const": "number" + }, + "ObjectTypeConst": { + "type": "string", + "format": "const", + "const": "object" + }, "PingRequestMethod": { "type": "string", "format": "const", "const": "ping" }, + "PrimitiveSchema": { + "description": "Primitive schema definition for elicitation properties.\n\nAccording to MCP 2025-06-18 specification, elicitation schemas must have\nproperties of primitive types only (string, number, integer, boolean, enum).", + "anyOf": [ + { + "description": "String property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/StringSchema" + } + ] + }, + { + "description": "Number property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/NumberSchema" + } + ] + }, + { + "description": "Integer property (with optional enum constraint)", + "allOf": [ + { + "$ref": "#/definitions/IntegerSchema" + } + ] + }, + { + "description": "Boolean property", + "allOf": [ + { + "$ref": "#/definitions/BooleanSchema" + } + ] + }, + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + } + ] + }, "ProgressNotificationMethod": { "type": "string", "format": "const", @@ -1900,6 +2198,96 @@ } ] }, + "StringFormat": { + "description": "String format types allowed by the MCP specification.", + "oneOf": [ + { + "description": "Email address format", + "type": "string", + "const": "email" + }, + { + "description": "URI format", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601)", + "type": "string", + "const": "date-time" + } + ] + }, + "StringSchema": { + "description": "Schema definition for string properties.\n\nCompliant with MCP 2025-06-18 specification for elicitation schemas.\nSupports only the fields allowed by the MCP spec:\n- format limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "type": "object", + "properties": { + "description": { + "description": "Human-readable description", + "type": [ + "string", + "null" + ] + }, + "format": { + "description": "String format - limited to: \"email\", \"uri\", \"date\", \"date-time\"", + "anyOf": [ + { + "$ref": "#/definitions/StringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "description": "Maximum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "minLength": { + "description": "Minimum string length", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "title": { + "description": "Optional title for the schema", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Type discriminator", + "allOf": [ + { + "$ref": "#/definitions/StringTypeConst" + } + ] + } + }, + "required": [ + "type" + ] + }, + "StringTypeConst": { + "type": "string", + "format": "const", + "const": "string" + }, "Tool": { "description": "A tool that can be used by a model.", "type": "object", diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index c9d550f7..10ee6611 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -29,6 +29,12 @@ pub struct UserInfo { // Mark as safe for elicitation elicit_safe!(UserInfo); +/// Simple greeting message +#[derive(Debug, Serialize, Deserialize)] +pub struct GreetingMessage { + pub text: String, +} + /// Simple tool request #[derive(Debug, Deserialize, JsonSchema)] pub struct GreetRequest {