diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 095e28e9..c9b5f4cd 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -32,6 +32,7 @@ const_string!(NumberTypeConst = "number"); const_string!(IntegerTypeConst = "integer"); const_string!(BooleanTypeConst = "boolean"); const_string!(EnumTypeConst = "string"); +const_string!(ArrayTypeConst = "array"); // ============================================================================= // PRIMITIVE SCHEMA DEFINITIONS @@ -41,10 +42,16 @@ const_string!(EnumTypeConst = "string"); /// /// According to MCP 2025-06-18 specification, elicitation schemas must have /// properties of primitive types only (string, number, integer, boolean, enum). +/// +/// Note: Put Enum as the first variant to avoid ambiguity during deserialization. +/// This is due to the fact that EnumSchema can contain StringSchema internally and serde +/// uses first match wins strategy when deserializing untagged enums. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum PrimitiveSchema { + /// Enum property (explicit enum schema) + Enum(EnumSchema), /// String property (with optional enum constraint) String(StringSchema), /// Number property (with optional enum constraint) @@ -53,8 +60,6 @@ pub enum PrimitiveSchema { Integer(IntegerSchema), /// Boolean property Boolean(BooleanSchema), - /// Enum property (explicit enum schema) - Enum(EnumSchema), } // ============================================================================= @@ -466,62 +471,413 @@ 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. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Represent single entry for titled item +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 { + // Note: Schemars produces "oneOf" here, but MCP spec uses "anyOf" + #[serde(rename = "anyOf", alias = "oneOf")] + 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-06-18 specification for elicitation schemas. +/// Enums must have string type for values and can optionally include human-readable names. +/// +/// # Example +/// +/// ```rust +/// use rmcp::model::*; +/// +/// let enum_schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) +/// .multiselect() +/// .min_items(1u64).expect("Min items should be correct value") +/// .max_items(4u64).expect("Max items should be correct value") +/// .description("Country code") +/// .build(); +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +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 match 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 + /// Clears any previously set titles + pub fn untitled(mut self) -> EnumSchemaBuilder { + self.enum_titles = Vec::new(); + 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) + } } // ============================================================================= @@ -972,13 +1328,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 +1397,7 @@ impl ElicitationSchemaBuilder { #[cfg(test)] mod tests { + use anyhow::{Result, anyhow}; use serde_json::json; use super::*; @@ -1087,22 +1444,90 @@ 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<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .multiselect() + .min_items(1u64) + .map_err(|e| anyhow!("{e}"))? + .max_items(4u64) + .map_err(|e| anyhow!("{e}"))? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema).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<()> { + 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"); + .map_err(|e| anyhow!("{e}"))? + .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<()> { + let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()]) + .enum_titles(vec![ + "United States".to_string(), + "United Kingdom".to_string(), + ]) + .map_err(|e| anyhow!("{e}"))? + .multiselect() + .min_items(1u64) + .map_err(|e| anyhow!("{e}"))? + .max_items(4u64) + .map_err(|e| anyhow!("{e}"))? + .description("Country code") + .build(); + let json = serde_json::to_value(&schema).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 +1546,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 +1601,129 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err(), "minimum must be <= maximum"); } + + #[cfg(feature = "schemars")] + mod schemars_tests { + use anyhow::Result; + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use crate::model::ElicitationSchema; + + #[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] + #[schemars(inline)] + #[schemars(extend("type" = "string"))] + enum TitledEnum { + #[schemars(title = "Title for the first value")] + #[default] + FirstValue, + #[schemars(title = "Title for the second value")] + SecondValue, + } + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + #[schemars(inline)] + enum UntitledEnum { + First, + Second, + Third, + } + + fn default_untitled_multi_select() -> Vec { + 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_for_enum_fields() -> Result<()> { + 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..2929d92c 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-06-18 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": { @@ -1379,8 +1416,16 @@ "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).", + "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).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", "anyOf": [ + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + }, { "description": "String property (with optional enum constraint)", "allOf": [ @@ -1412,14 +1457,6 @@ "$ref": "#/definitions/BooleanSchema" } ] - }, - { - "description": "Enum property (explicit enum schema)", - "allOf": [ - { - "$ref": "#/definitions/EnumSchema" - } - ] } ] }, @@ -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..2929d92c 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-06-18 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": { @@ -1379,8 +1416,16 @@ "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).", + "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).\n\nNote: Put Enum as the first variant to avoid ambiguity during deserialization.\nThis is due to the fact that EnumSchema can contain StringSchema internally and serde\nuses first match wins strategy when deserializing untagged enums.", "anyOf": [ + { + "description": "Enum property (explicit enum schema)", + "allOf": [ + { + "$ref": "#/definitions/EnumSchema" + } + ] + }, { "description": "String property (with optional enum constraint)", "allOf": [ @@ -1412,14 +1457,6 @@ "$ref": "#/definitions/BooleanSchema" } ] - }, - { - "description": "Enum property (explicit enum schema)", - "allOf": [ - { - "$ref": "#/definitions/EnumSchema" - } - ] } ] }, @@ -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/Cargo.toml b/examples/servers/Cargo.toml index 8bf97f2a..57e2ccb4 100644 --- a/examples/servers/Cargo.toml +++ b/examples/servers/Cargo.toml @@ -105,3 +105,7 @@ path = "src/completion_stdio.rs" [[example]] name = "servers_progress_demo" path = "src/progress_demo.rs" + +[[example]] +name = "elicitation_enum_select" +path = "src/elicitation_enum_inference.rs" diff --git a/examples/servers/src/elicitation_enum_inference.rs b/examples/servers/src/elicitation_enum_inference.rs new file mode 100644 index 00000000..2ecec311 --- /dev/null +++ b/examples/servers/src/elicitation_enum_inference.rs @@ -0,0 +1,189 @@ +//! Demonstration how to use enum selection in elicitation forms. +//! +//! This example server allows users to select enum values via elicitation forms. +//! To work with enum inference, it is required to use specific `schemars` attributes and apply some workarounds: +//! - Use `#[schemars(inline)]` to ensure the enum is inlined in the schema. +//! - Use `#[schemars(extend("type" = "string"))]` to manually add the required type field, since `schemars` does not provide it for enums. +//! - Optionally, use `#[schemars(title = "...")]` to provide titles for enum variants. +//! For more details, see: https://docs.rs/schemars/latest/schemars/ +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; + +use rmcp::{ + ErrorData as McpError, ServerHandler, elicit_safe, + handler::server::router::tool::ToolRouter, + model::*, + service::{RequestContext, RoleServer}, + tool, tool_handler, tool_router, + transport::{ + StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, + }, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +const BIND_ADDRESS: &str = "127.0.0.1:8000"; + +#[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] +// inline attribute required to work for schema inference in elicitation forms +#[schemars(inline)] +// schemars does not provide required type field for enums, so we extend it here +#[schemars(extend("type" = "string"))] +enum TitledEnum { + #[schemars(title = "Title for the first value")] + #[default] + FirstValue, + #[schemars(title = "Title for the second value")] + SecondValue, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +// inline attribute required to work for schema inference in elicitation forms +#[schemars(inline)] +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 SelectEnumForm { + 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, +} + +impl Display for SelectEnumForm { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let s = format!( + "Current Selections:\n\ + Single Select Untitled: {:?}\n\ + Single Select Titled: {:?}\n\ + Multi Select Untitled: {:?}\n\ + Multi Select Titled: {:?}\n", + self.single_select_untitled, + self.single_select_titled, + self.multi_select_untitled, + self.multi_select_titled, + ); + write!(f, "{s}") + } +} + +elicit_safe!(SelectEnumForm); + +#[derive(Clone)] +struct ElicitationEnumFormServer { + selection: Arc>, + tool_router: ToolRouter, +} + +#[tool_router] +impl ElicitationEnumFormServer { + pub fn new() -> Self { + Self { + selection: Arc::new(Mutex::new(SelectEnumForm { + single_select_untitled: UntitledEnum::First, + single_select_titled: TitledEnum::FirstValue, + multi_select_untitled: vec![UntitledEnum::Second], + multi_select_titled: vec![TitledEnum::SecondValue], + })), + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Get current enum selection form")] + async fn get_enum_form(&self) -> Result { + let guard = self.selection.lock().await; + Ok(CallToolResult::success(vec![Content::text(format!( + "{}", + *guard + ))])) + } + + #[tool(description = "Set enum selection via elicitation form")] + async fn set_enum_form( + &self, + context: RequestContext, + ) -> Result { + match context + .peer + .elicit::("Please provide your selection".to_string()) + .await + { + Ok(Some(form)) => { + let mut guard = self.selection.lock().await; + *guard = form; + Ok(CallToolResult::success(vec![Content::text(format!( + "Updated Selection:\n{}", + *guard + ))])) + } + Ok(None) => { + return Ok(CallToolResult::success(vec![Content::text( + "Elicitation cancelled by user.", + )])); + } + Err(err) => { + return Err(McpError::internal_error( + format!("Elicitation failed: {err}"), + None, + )); + } + } + } +} + +#[tool_handler] +impl ServerHandler for ElicitationEnumFormServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation::from_build_env(), + instructions: Some( + "Simple server demonstrating elicitation for enum selection".to_string(), + ), + ..Default::default() + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".to_string().into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let service = StreamableHttpService::new( + || Ok(ElicitationEnumFormServer::new()), + LocalSessionManager::default().into(), + Default::default(), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let tcp_listener = tokio::net::TcpListener::bind(BIND_ADDRESS).await?; + let _ = axum::serve(tcp_listener, router) + .with_graceful_shutdown(async { tokio::signal::ctrl_c().await.unwrap() }) + .await; + Ok(()) +}