diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 095e28e9..efe1b573 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -19,6 +19,7 @@ use std::{borrow::Cow, collections::BTreeMap}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::{const_string, model::ConstString}; @@ -32,6 +33,7 @@ const_string!(NumberTypeConst = "number"); const_string!(IntegerTypeConst = "integer"); const_string!(BooleanTypeConst = "boolean"); const_string!(EnumTypeConst = "string"); +const_string!(ArrayTypeConst = "array"); // ============================================================================= // PRIMITIVE SCHEMA DEFINITIONS @@ -466,62 +468,410 @@ impl BooleanSchema { /// 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. +/// Represent single entry for titled item #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ConstTitle { + #[serde(rename = "const")] + pub const_: String, + pub title: String, +} + +/// Legacy enum schema, keep for backward compatibility +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct EnumSchema { - /// Type discriminator (always "string" for enums) +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct LegacyEnumSchema { #[serde(rename = "type")] pub type_: StringTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "enum")] + pub enum_: Vec, + pub enum_names: Option>, +} - /// Allowed enum values (string values only per MCP spec) +/// Untitled single-select +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct UntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + pub type_: StringTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, #[serde(rename = "enum")] - pub enum_values: Vec, + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} - /// Optional human-readable names for each enum value +/// Titled single-select +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct TitledSingleSelectEnumSchema { + #[serde(rename = "type")] + pub type_: StringTypeConst, #[serde(skip_serializing_if = "Option::is_none")] - pub enum_names: Option>, + pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} - /// Optional title for the schema +/// Combined single-select +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +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))] +pub struct UntitledItems { + #[serde(rename = "type")] + pub type_: StringTypeConst, + #[serde(rename = "enum")] + pub enum_: Vec, +} + +/// Items for titled multi-select options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct TitledItems { + #[serde(rename = "anyOf")] + pub any_of: Vec, +} + +/// Multi-select untitled options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + pub type_: ArrayTypeConst, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + pub items: UntitledItems, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, +} - /// Human-readable description +/// Multi-select titled options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct TitledMultiSelectEnumSchema { + #[serde(rename = "type")] + pub type_: ArrayTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + pub items: TitledItems, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, } -impl EnumSchema { - /// Create a new enum schema with string values - pub fn new(values: Vec) -> Self { +/// Multi-select enum options +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum MultiSelectEnumSchema { + Untitled(UntitledMultiSelectEnumSchema), + Titled(TitledMultiSelectEnumSchema), +} + +/// Compliant with MCP 2025-11-25 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)] +pub enum EnumSchema { + Single(SingleSelectEnumSchema), + Multi(MultiSelectEnumSchema), + Legacy(LegacyEnumSchema), +} + +/// Builder for EnumSchema +#[derive(Debug)] +pub struct EnumSchemaBuilder { + /// Enum values + enum_values: Vec, + /// If true generate SingleSelect EnumSchema, MultiSelect otherwise + single_select: bool, + /// If true generate Titled EnumSchema, UnTitled otherwise + titled: bool, + /// Title of EnumSchema + schema_title: Option>, + /// Description of EnumSchema + description: Option>, + /// Titles of given enum values + enum_titles: Vec, + /// Minimum number of items to choose for MultiSelect + min_items: Option, + /// Maximum number of items to choose for MultiSelect + max_items: Option, + /// Default values for enum + default: Vec, +} + +impl Default for EnumSchemaBuilder { + fn default() -> Self { Self { - type_: StringTypeConst, - enum_values: values, - enum_names: None, - title: None, + schema_title: None, description: None, + single_select: true, + titled: false, + enum_titles: Vec::new(), + enum_values: Vec::new(), + min_items: None, + max_items: None, + default: Vec::new(), } } +} - /// Set enum names (human-readable names for each enum value) - pub fn enum_names(mut self, names: Vec) -> Self { - self.enum_names = Some(names); +macro_rules! enum_schema_builder { + ($field:ident: $type:ty) => { + pub fn $field(mut self, value: $type) -> Self { + self.$field = Some(value.into()); + self + } + }; +} + +/// Enum selection builder +impl EnumSchemaBuilder { + pub fn new(values: Vec) -> EnumSchemaBuilder { + EnumSchemaBuilder { + enum_values: values, + single_select: true, + titled: false, + ..Default::default() + } + } + + /// Set titles to enum values. Also, implicitly set this enum schema as titled + pub fn enum_titles(mut self, titles: Vec) -> Result { + if titles.len() != self.enum_values.len() { + return Err(format!( + "Provided number of titles do not matched to number of values: expected {}, but got {}", + self.enum_values.len(), + titles.len() + )); + } + self.titled = true; + self.enum_titles = titles; + Ok(self) + } + + /// Set enum as single-select + /// If it was multi-select, clear default values + pub fn single_select(mut self) -> EnumSchemaBuilder { + if !self.single_select { + self.default = Vec::new(); + } + self.single_select = true; self } - /// Set title - pub fn title(mut self, title: impl Into>) -> Self { - self.title = Some(title.into()); + /// Set enum as multi-select + /// If it was single-select, clear default value + pub fn multiselect(mut self) -> EnumSchemaBuilder { + if self.single_select { + self.default = Vec::new(); + } + self.single_select = false; self } - /// Set description - pub fn description(mut self, description: impl Into>) -> Self { - self.description = Some(description.into()); + /// Set enum as untitled + pub fn untitled(mut self) -> EnumSchemaBuilder { + self.titled = false; self } + + /// Set default value for single-select enum + pub fn single_select_default( + mut self, + default_value: String, + ) -> Result { + if self.single_select { + return Err( + "Set single default value available only when the builder is set to single-select. \ + Use multi_select_default method for multi-select options", + ); + } + self.default = vec![default_value]; + Ok(self) + } + + /// Set default value for multi-select enum + pub fn multi_select_default( + mut self, + default_values: Vec, + ) -> Result { + if self.single_select { + return Err( + "Set multiple default values available only when the builder is set to multi-select. \ + Use single_select_default method for single-select options", + ); + } + 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 { + if let Some(max) = self.max_items + && value > max + { + return Err("Provided value is greater than max_items"); + } + 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 { + if let Some(min) = self.min_items + && value < min + { + return Err("Provided value is less than min_items"); + } + self.max_items = Some(value); + Ok(self) + } + + enum_schema_builder!(schema_title: impl Into>); + enum_schema_builder!(description: impl Into>); + + /// Build enum schema + pub fn build(mut self) -> EnumSchema { + match (self.single_select, self.titled) { + (true, false) => EnumSchema::Single(SingleSelectEnumSchema::Untitled( + UntitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: self.schema_title, + description: self.description, + enum_: self.enum_values, + default: self.default.pop(), + }, + )), + (true, true) => EnumSchema::Single(SingleSelectEnumSchema::Titled( + TitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: self.schema_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(), + }, + )), + (false, false) => EnumSchema::Multi(MultiSelectEnumSchema::Untitled( + UntitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: self.schema_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) + }, + }, + )), + (false, true) => { + EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: self.schema_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 builder = EnumSchema::builder(vec!["A".to_string(), "B".to_string()]); + /// ``` + pub fn builder(values: Vec) -> EnumSchemaBuilder { + EnumSchemaBuilder::new(values) + } } // ============================================================================= @@ -599,7 +949,63 @@ impl ElicitationSchema { /// 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)) + let mut schema_value = Value::Object(schema); + let defs_snapshot = schema_value.get("$defs").cloned(); + let definitions = schema_value.get("definitions").cloned(); + + if let Some(properties) = schema_value + .get_mut("properties") + .and_then(|value| value.as_object_mut()) + { + for property in properties.values_mut() { + normalize_property(property, defs_snapshot.as_ref(), definitions.as_ref()); + } + } + + let Value::Object(mut schema_map) = schema_value else { + return Err(::custom( + "Elicitation schema root must be an object", + )); + }; + + let properties_value = match schema_map.remove("properties") { + Some(Value::Object(map)) => map, + Some(_) => { + return Err(::custom( + "Elicitation schema properties must be an object", + )); + } + None => serde_json::Map::new(), + }; + + let mut properties = BTreeMap::new(); + for (name, value) in properties_value { + let primitive = if is_enum_schema(&value) { + PrimitiveSchema::Enum(serde_json::from_value(value)?) + } else { + serde_json::from_value(value)? + }; + properties.insert(name, primitive); + } + + let mut elicitation_schema = ElicitationSchema::new(properties); + + if let Some(required_value) = schema_map.get("required") { + let required: Vec = serde_json::from_value(required_value.clone())?; + if !required.is_empty() { + elicitation_schema.required = Some(required); + } + } + + if let Some(title_value) = schema_map.get("title").and_then(Value::as_str) { + elicitation_schema.title = Some(Cow::Owned(title_value.to_string())); + } + + if let Some(description_value) = schema_map.get("description").and_then(Value::as_str) { + elicitation_schema.description = Some(Cow::Owned(description_value.to_string())); + } + + Ok(elicitation_schema) } /// Generate an ElicitationSchema from a Rust type that implements JsonSchema @@ -672,6 +1078,90 @@ impl ElicitationSchema { } } +fn is_enum_schema(value: &Value) -> bool { + match value { + Value::Object(map) => { + if map.contains_key("enum") || map.contains_key("oneOf") || map.contains_key("anyOf") { + return true; + } + if let Some(items) = map.get("items") { + return is_enum_schema(items); + } + false + } + _ => false, + } +} + +fn normalize_property(value: &mut Value, defs: Option<&Value>, definitions: Option<&Value>) { + let reference = value + .as_object() + .and_then(|map| map.get("$ref").and_then(Value::as_str)) + .map(|s| s.to_owned()); + if let Some(reference) = reference { + if let Some(Value::Object(mut resolved_map)) = resolve_ref(&reference, defs, definitions) { + let overrides = value.as_object().map(|map| { + let mut overrides = map.clone(); + overrides.remove("$ref"); + overrides + }); + if let Some(overrides) = overrides { + for (key, override_value) in overrides { + resolved_map.insert(key, override_value); + } + } + *value = Value::Object(resolved_map); + normalize_property(value, defs, definitions); + return; + } + } + + let Value::Object(map) = value else { + return; + }; + + let is_array = map + .get("type") + .and_then(Value::as_str) + .map(|type_name| type_name == "array") + .unwrap_or(false); + if is_array { + if let Some(items) = map.get_mut("items") { + normalize_property(items, defs, definitions); + if let Some(items_object) = items.as_object_mut() { + if let Some(one_of) = items_object.remove("oneOf") { + items_object.insert("anyOf".to_string(), one_of); + } + } + } + return; + } + + ensure_enum_string_type(map); +} + +fn ensure_enum_string_type(map: &mut serde_json::Map) { + if (map.contains_key("enum") || map.contains_key("oneOf") || map.contains_key("anyOf")) + && !map.contains_key("type") + { + map.insert("type".to_string(), Value::String("string".to_string())); + } +} + +fn resolve_ref( + reference: &str, + defs: Option<&Value>, + definitions: Option<&Value>, +) -> Option { + resolve_with(reference, "#/$defs", defs) + .or_else(|| resolve_with(reference, "#/definitions", definitions)) +} + +fn resolve_with(reference: &str, prefix: &str, root: Option<&Value>) -> Option { + let remainder = reference.strip_prefix(prefix)?; + root?.pointer(remainder).cloned() +} + // ============================================================================= // BUILDER // ============================================================================= @@ -972,13 +1462,13 @@ impl ElicitationSchemaBuilder { // 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))) + pub fn required_enum(self, name: impl Into, enum_schema: EnumSchema) -> Self { + self.required_property(name, PrimitiveSchema::Enum(enum_schema)) } /// Add an optional enum property - pub fn optional_enum(self, name: impl Into, values: Vec) -> Self { - self.property(name, PrimitiveSchema::Enum(EnumSchema::new(values))) + pub fn optional_enum(self, name: impl Into, enum_schema: EnumSchema) -> Self { + self.property(name, PrimitiveSchema::Enum(enum_schema)) } /// Mark an existing property as required @@ -1041,6 +1531,8 @@ impl ElicitationSchemaBuilder { #[cfg(test)] mod tests { + use std::error::Error; + use serde_json::json; use super::*; @@ -1087,22 +1579,84 @@ mod tests { } #[test] - fn test_enum_schema_serialization() { - let schema = EnumSchema::new(vec!["US".to_string(), "UK".to_string()]) - .enum_names(vec![ + 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() -> Result<(), Box> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .multiselect() + .min_items(1u64)? + .max_items(4u64)? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema).unwrap(); + + 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() -> Result<(), Box> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ "United States".to_string(), "United Kingdom".to_string(), - ]) - .description("Country code"); + ])? + .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["enumNames"], - json!(["United States", "United Kingdom"]) + 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_titled_multi_select_serialization() -> Result<(), Box> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ])? + .multiselect() + .min_items(1u64)? + .max_items(4u64)? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema).unwrap(); + + 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] @@ -1121,14 +1675,13 @@ mod tests { #[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( - "country", - vec!["US".to_string(), "UK".to_string(), "CA".to_string()], - ) + .required_enum("country", enum_schema) .description("User registration") .build() .unwrap(); @@ -1177,4 +1730,127 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err(), "minimum must be <= maximum"); } + + #[cfg(feature = "schemars")] + mod schemars_tests { + use std::error::Error; + + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use crate::model::ElicitationSchema; + + #[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] + enum TitledEnum { + #[schemars(title = "Title for the first value")] + #[default] + FirstValue, + #[schemars(title = "Title for the second value")] + SecondValue, + } + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + enum UntitledEnum { + First, + Second, + Third, + } + + fn default_untitled_multi_select() -> Vec { + 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, + #[schemars( + title = "Multi Select Titled", + description = "Multi Select Description" + )] + pub multi_select_titled: Vec, + } + + #[test] + fn test_schema_inference() -> Result<(), Box> { + let schema = ElicitationSchema::from_type::()?; + + 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(()) + } + } } diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 8dc6f160..f03edcf2 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -406,7 +406,7 @@ async fn test_elicitation_structured_schemas() { .optional_bool("newsletter", false) .required_enum( "country", - vec!["US".to_string(), "UK".to_string(), "CA".to_string()], + EnumSchema::builder(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]).build(), ) .description("User registration information") .build() 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 223989cb..b4421f82 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,11 @@ } } }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" + }, "BooleanSchema": { "description": "Schema definition for boolean properties.", "type": "object", @@ -445,6 +450,22 @@ "values" ] }, + "ConstTitle": { + "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", + "type": "object", + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ] + }, "ContextInclusion": { "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", "oneOf": [ @@ -664,52 +685,17 @@ "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" - } + "description": "Compliant with MCP 2025-11-25 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", + "anyOf": [ + { + "$ref": "#/definitions/SingleSelectEnumSchema" }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] + { + "$ref": "#/definitions/MultiSelectEnumSchema" }, - "type": { - "description": "Type discriminator (always \"string\" for enums)", - "allOf": [ - { - "$ref": "#/definitions/StringTypeConst" - } - ] + { + "$ref": "#/definitions/LegacyEnumSchema" } - }, - "required": [ - "type", - "enum" ] }, "ErrorCode": { @@ -1028,6 +1014,46 @@ "format": "const", "const": "2.0" }, + "LegacyEnumSchema": { + "description": "Legacy enum schema, keep for backward compatibility", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, "ListPromptsResult": { "type": "object", "properties": { @@ -1213,6 +1239,17 @@ } } }, + "MultiSelectEnumSchema": { + "description": "Multi-select enum options", + "anyOf": [ + { + "$ref": "#/definitions/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledMultiSelectEnumSchema" + } + ] + }, "Notification": { "type": "object", "properties": { @@ -2198,6 +2235,17 @@ } ] }, + "SingleSelectEnumSchema": { + "description": "Combined single-select", + "anyOf": [ + { + "$ref": "#/definitions/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledSingleSelectEnumSchema" + } + ] + }, "StringFormat": { "description": "String format types allowed by the MCP specification.", "oneOf": [ @@ -2288,6 +2336,111 @@ "format": "const", "const": "string" }, + "TitledItems": { + "description": "Items for titled multi-select options", + "type": "object", + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + } + }, + "required": [ + "anyOf" + ] + }, + "TitledMultiSelectEnumSchema": { + "description": "Multi-select titled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/TitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "TitledSingleSelectEnumSchema": { + "description": "Titled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "oneOf" + ] + }, "Tool": { "description": "A tool that can be used by a model.", "type": "object", @@ -2414,6 +2567,115 @@ ] } } + }, + "UntitledItems": { + "description": "Items for untitled multi-select options", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "UntitledMultiSelectEnumSchema": { + "description": "Multi-select untitled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/UntitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "UntitledSingleSelectEnumSchema": { + "description": "Untitled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] } } } \ No newline at end of file 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 223989cb..b4421f82 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,11 @@ } } }, + "ArrayTypeConst": { + "type": "string", + "format": "const", + "const": "array" + }, "BooleanSchema": { "description": "Schema definition for boolean properties.", "type": "object", @@ -445,6 +450,22 @@ "values" ] }, + "ConstTitle": { + "description": "Schema definition for enum properties.\n\nRepresent single entry for titled item", + "type": "object", + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ] + }, "ContextInclusion": { "description": "Specifies how much context should be included in sampling requests.\n\nThis allows clients to control what additional context information\nshould be provided to the LLM when processing sampling requests.", "oneOf": [ @@ -664,52 +685,17 @@ "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" - } + "description": "Compliant with MCP 2025-11-25 specification for elicitation schemas.\nEnums must have string type for values and can optionally include human-readable names.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet enum_schema = EnumSchema::builder(vec![\"US\".to_string(), \"UK\".to_string()])\n .multiselect()\n .min_items(1u64).expect(\"Min items should be correct value\")\n .max_items(4u64).expect(\"Max items should be correct value\")\n .description(\"Country code\")\n .build();\n```", + "anyOf": [ + { + "$ref": "#/definitions/SingleSelectEnumSchema" }, - "title": { - "description": "Optional title for the schema", - "type": [ - "string", - "null" - ] + { + "$ref": "#/definitions/MultiSelectEnumSchema" }, - "type": { - "description": "Type discriminator (always \"string\" for enums)", - "allOf": [ - { - "$ref": "#/definitions/StringTypeConst" - } - ] + { + "$ref": "#/definitions/LegacyEnumSchema" } - }, - "required": [ - "type", - "enum" ] }, "ErrorCode": { @@ -1028,6 +1014,46 @@ "format": "const", "const": "2.0" }, + "LegacyEnumSchema": { + "description": "Legacy enum schema, keep for backward compatibility", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, "ListPromptsResult": { "type": "object", "properties": { @@ -1213,6 +1239,17 @@ } } }, + "MultiSelectEnumSchema": { + "description": "Multi-select enum options", + "anyOf": [ + { + "$ref": "#/definitions/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledMultiSelectEnumSchema" + } + ] + }, "Notification": { "type": "object", "properties": { @@ -2198,6 +2235,17 @@ } ] }, + "SingleSelectEnumSchema": { + "description": "Combined single-select", + "anyOf": [ + { + "$ref": "#/definitions/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/TitledSingleSelectEnumSchema" + } + ] + }, "StringFormat": { "description": "String format types allowed by the MCP specification.", "oneOf": [ @@ -2288,6 +2336,111 @@ "format": "const", "const": "string" }, + "TitledItems": { + "description": "Items for titled multi-select options", + "type": "object", + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + } + }, + "required": [ + "anyOf" + ] + }, + "TitledMultiSelectEnumSchema": { + "description": "Multi-select titled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/TitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "TitledSingleSelectEnumSchema": { + "description": "Titled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/ConstTitle" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "oneOf" + ] + }, "Tool": { "description": "A tool that can be used by a model.", "type": "object", @@ -2414,6 +2567,115 @@ ] } } + }, + "UntitledItems": { + "description": "Items for untitled multi-select options", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] + }, + "UntitledMultiSelectEnumSchema": { + "description": "Multi-select untitled options", + "type": "object", + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/UntitledItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/ArrayTypeConst" + } + }, + "required": [ + "type", + "items" + ] + }, + "UntitledSingleSelectEnumSchema": { + "description": "Untitled single-select", + "type": "object", + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/StringTypeConst" + } + }, + "required": [ + "type", + "enum" + ] } } } \ No newline at end of file diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 10ee6611..2061df6f 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -2,7 +2,10 @@ //! //! Demonstrates user name collection via elicitation -use std::sync::Arc; +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; use anyhow::Result; use rmcp::{ @@ -18,12 +21,32 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tracing_subscriber::{self, EnvFilter}; +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] +pub enum UserType { + #[schemars(title = "Guest User")] + #[default] + Guest, + #[schemars(title = "Admin User")] + Admin, +} + +impl Display for UserType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + UserType::Guest => write!(f, "Guest"), + UserType::Admin => write!(f, "Admin"), + } + } +} + /// User information request -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(description = "User information")] pub struct UserInfo { #[schemars(description = "User's name")] pub name: String, + #[schemars(title = "What kind of user you are?", default)] + pub user_type: UserType, } // Mark as safe for elicitation @@ -44,14 +67,14 @@ pub struct GreetRequest { /// Simple server with elicitation #[derive(Clone)] pub struct ElicitationServer { - user_name: Arc>>, + user_info: Arc>>, tool_router: ToolRouter, } impl ElicitationServer { pub fn new() -> Self { Self { - user_name: Arc::new(Mutex::new(None)), + user_info: Arc::new(Mutex::new(None)), tool_router: Self::tool_router(), } } @@ -71,11 +94,11 @@ impl ElicitationServer { context: RequestContext, Parameters(request): Parameters, ) -> Result { - // Check if we have user name - let current_name = self.user_name.lock().await.clone(); + // Check if we have user info + let current_info = self.user_info.lock().await.clone(); - let user_name = if let Some(name) = current_name { - name + let user_info = if let Some(info) = current_info { + info } else { // Request user name via elicitation match context @@ -84,24 +107,32 @@ impl ElicitationServer { .await { Ok(Some(user_info)) => { - let name = user_info.name.clone(); - *self.user_name.lock().await = Some(name.clone()); - name + *self.user_info.lock().await = Some(user_info.clone()); + user_info + } + Ok(None) => UserInfo { + name: "Guest".to_string(), + user_type: UserType::Guest, + }, // Never happen if client checks schema + Err(err) => { + tracing::error!("Failed to elicit user info: {:?}", err); + UserInfo { + name: "Unknown".to_string(), + user_type: UserType::Guest, + } } - Ok(None) => "Guest".to_string(), // Never happen if client checks schema - Err(_) => "Unknown".to_string(), } }; Ok(CallToolResult::success(vec![Content::text(format!( - "{} {}!", - request.greeting, user_name + "{} {}! You are {}", + request.greeting, user_info.name, user_info.user_type ))])) } #[tool(description = "Reset stored user name")] async fn reset_name(&self) -> Result { - *self.user_name.lock().await = None; + *self.user_info.lock().await = None; Ok(CallToolResult::success(vec![Content::text( "User name reset. Next greeting will ask for name again.".to_string(), )])) @@ -128,16 +159,16 @@ async fn main() -> Result<()> { .with_env_filter(EnvFilter::from_default_env()) .init(); - println!("Simple MCP Elicitation Demo"); + eprintln!("Simple MCP Elicitation Demo"); // Get current executable path for Inspector let current_exe = std::env::current_exe() .map(|path| path.display().to_string()) .unwrap(); - println!("To test with MCP Inspector:"); - println!("1. Run: npx @modelcontextprotocol/inspector"); - println!("2. Enter server command: {}", current_exe); + eprintln!("To test with MCP Inspector:"); + eprintln!("1. Run: npx @modelcontextprotocol/inspector"); + eprintln!("2. Enter server command: {}", current_exe); let service = ElicitationServer::new() .serve(stdio())