From e151793e05065a490f0bee1bf3336737e653ad6f Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Sun, 9 Nov 2025 17:02:22 +0100 Subject: [PATCH 1/2] feat: add default value support to elicitation schemas (SEP-1034) Implement optional default values for StringSchema, NumberSchema, and EnumSchema following the BooleanSchema pattern. This allows forms to be pre-populated with sensible defaults, improving user experience in elicitation workflows. Changes: - Add optional `default` field to StringSchema (Cow<'static, str>) - Add optional `default` field to NumberSchema (f64) - Add optional `default` field to EnumSchema (String) - Add `with_default()` builder methods to all three schemas - Add comprehensive tests for default value serialization and deserialization - Update elicitation example to demonstrate default values usage All defaults are optional (Option) for backward compatibility and use skip_serializing_if to ensure old clients ignore the new field. --- crates/rmcp/src/model/elicitation_schema.rs | 143 ++++++++++++++++++++ examples/servers/src/elicitation_stdio.rs | 86 +++++++++++- 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 095e28e9..dcdeb6ec 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -108,6 +108,10 @@ pub struct StringSchema { /// String format - limited to: "email", "uri", "date", "date-time" #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, } impl Default for StringSchema { @@ -119,6 +123,7 @@ impl Default for StringSchema { min_length: None, max_length: None, format: None, + default: None, } } } @@ -208,6 +213,12 @@ impl StringSchema { self.format = Some(format); self } + + /// Set default value + pub fn with_default(mut self, default: impl Into>) -> Self { + self.default = Some(default.into()); + self + } } // ============================================================================= @@ -241,6 +252,10 @@ pub struct NumberSchema { /// Maximum value (inclusive) #[serde(skip_serializing_if = "Option::is_none")] pub maximum: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, } impl Default for NumberSchema { @@ -251,6 +266,7 @@ impl Default for NumberSchema { description: None, minimum: None, maximum: None, + default: None, } } } @@ -302,6 +318,12 @@ impl NumberSchema { self.description = Some(description.into()); self } + + /// Set default value + pub fn with_default(mut self, default: f64) -> Self { + self.default = Some(default); + self + } } // ============================================================================= @@ -491,6 +513,10 @@ pub struct EnumSchema { /// 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 EnumSchema { @@ -502,6 +528,7 @@ impl EnumSchema { enum_names: None, title: None, description: None, + default: None, } } @@ -522,6 +549,12 @@ impl EnumSchema { self.description = Some(description.into()); self } + + /// Set default value + pub fn with_default(mut self, default: String) -> Self { + self.default = Some(default); + self + } } // ============================================================================= @@ -1177,4 +1210,114 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err(), "minimum must be <= maximum"); } + + #[test] + fn test_string_schema_with_default() { + let schema = StringSchema::new() + .with_default("example@test.com") + .description("Email with default"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert_eq!(json["default"], "example@test.com"); + assert_eq!(json["description"], "Email with default"); + } + + #[test] + fn test_string_schema_without_default() { + let schema = StringSchema::new().description("Email without default"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert!(json.get("default").is_none()); + } + + #[test] + fn test_number_schema_with_default() { + let schema = NumberSchema::new() + .range(0.0, 100.0) + .with_default(50.0) + .description("Percentage with default"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "number"); + assert_eq!(json["default"], 50.0); + assert_eq!(json["minimum"], 0.0); + assert_eq!(json["maximum"], 100.0); + } + + #[test] + fn test_number_schema_without_default() { + let schema = NumberSchema::new().range(0.0, 100.0); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "number"); + assert!(json.get("default").is_none()); + } + + #[test] + fn test_enum_schema_with_default() { + let schema = EnumSchema::new(vec!["US".to_string(), "UK".to_string(), "CA".to_string()]) + .with_default("US".to_string()) + .description("Country with default"); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert_eq!(json["enum"], json!(["US", "UK", "CA"])); + assert_eq!(json["default"], "US"); + } + + #[test] + fn test_enum_schema_without_default() { + let schema = EnumSchema::new(vec!["US".to_string(), "UK".to_string()]); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "string"); + assert!(json.get("default").is_none()); + } + + #[test] + fn test_elicitation_schema_with_defaults() { + let schema = ElicitationSchema::builder() + .required_string_with("name", |s| s.length(1, 100)) + .property( + "email", + PrimitiveSchema::String(StringSchema::email().with_default("user@example.com")), + ) + .property( + "age", + PrimitiveSchema::Number(NumberSchema::new().range(0.0, 150.0).with_default(25.0)), + ) + .property( + "country", + PrimitiveSchema::Enum( + EnumSchema::new(vec!["US".to_string(), "UK".to_string()]) + .with_default("US".to_string()), + ), + ) + .description("User registration with defaults") + .build() + .unwrap(); + + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["type"], "object"); + assert_eq!(json["properties"]["email"]["default"], "user@example.com"); + assert_eq!(json["properties"]["age"]["default"], 25.0); + assert_eq!(json["properties"]["country"]["default"], "US"); + assert!(json["properties"]["name"].get("default").is_none()); + } + + #[test] + fn test_default_serialization_roundtrip() { + let original = StringSchema::new() + .with_default("test") + .description("Test schema"); + + let json = serde_json::to_value(&original).unwrap(); + let deserialized: StringSchema = serde_json::from_value(json).unwrap(); + + assert_eq!(original, deserialized); + assert_eq!(deserialized.default, Some(Cow::Borrowed("test"))); + } } diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 10ee6611..16f09f61 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -1,6 +1,6 @@ //! Simple MCP Server with Elicitation //! -//! Demonstrates user name collection via elicitation +//! Demonstrates user name collection via elicitation and default values use std::sync::Arc; @@ -106,6 +106,90 @@ impl ElicitationServer { "User name reset. Next greeting will ask for name again.".to_string(), )])) } + + #[tool(description = "Reply to email with default values demonstration")] + async fn reply_email( + &self, + context: RequestContext, + ) -> Result { + // Build schema with default values for email reply + let schema = ElicitationSchema::builder() + .string_property("recipient", |s| { + s.format(StringFormat::Email) + .with_default("sender@example.com") + .description("Email recipient") + }) + .string_property("cc", |s| { + s.format(StringFormat::Email) + .with_default("team@example.com") + .description("CC recipients") + }) + .property( + "priority", + PrimitiveSchema::Enum( + EnumSchema::new(vec![ + "low".to_string(), + "normal".to_string(), + "high".to_string(), + ]) + .with_default("normal".to_string()) + .description("Email priority"), + ), + ) + .number_property("confidence", |n| { + n.range(0.0, 1.0) + .with_default(0.8) + .description("Reply confidence score") + }) + .required_string("subject") + .required_string("body") + .description("Email reply configuration with defaults") + .build() + .map_err(|e| McpError::internal_error(format!("Schema build error: {}", e), None))?; + + // Request email details with pre-filled defaults + let response = context + .peer + .create_elicitation(CreateElicitationRequestParam { + message: "Configure email reply".to_string(), + requested_schema: schema, + }) + .await + .map_err(|e| McpError::internal_error(format!("Elicitation error: {}", e), None))?; + + match response.action { + ElicitationAction::Accept => { + if let Some(reply_data) = response.content { + let recipient = reply_data + .get("recipient") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let subject = reply_data + .get("subject") + .and_then(|v| v.as_str()) + .unwrap_or("No subject"); + let priority = reply_data + .get("priority") + .and_then(|v| v.as_str()) + .unwrap_or("normal"); + + Ok(CallToolResult::success(vec![Content::text(format!( + "Email reply configured:\nTo: {}\nSubject: {}\nPriority: {}\n\nDefaults were used for pre-filling the form!", + recipient, subject, priority + ))])) + } else { + Ok(CallToolResult::success(vec![Content::text( + "Email accepted but no content provided".to_string(), + )])) + } + } + ElicitationAction::Decline | ElicitationAction::Cancel => { + Ok(CallToolResult::success(vec![Content::text( + "Email reply cancelled".to_string(), + )])) + } + } + } } #[tool_handler] From f9d903923f1a7f9c380fd25bd443d97ad8aaa04b Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Sun, 9 Nov 2025 18:04:56 +0100 Subject: [PATCH 2/2] chore: update JSON schema and format code - Update server JSON RPC message schema with new default fields - Apply code formatting to elicitation example --- .../server_json_rpc_message_schema.json | 22 +++++++++++++++++++ ...erver_json_rpc_message_schema_current.json | 22 +++++++++++++++++++ examples/servers/src/elicitation_stdio.rs | 8 +++---- 3 files changed, 47 insertions(+), 5 deletions(-) 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 663a6894..eef1b88f 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 @@ -667,6 +667,13 @@ "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": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Human-readable description", "type": [ @@ -1320,6 +1327,14 @@ "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": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, "description": { "description": "Human-readable description", "type": [ @@ -2227,6 +2242,13 @@ "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": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Human-readable description", "type": [ 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 663a6894..eef1b88f 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 @@ -667,6 +667,13 @@ "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": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Human-readable description", "type": [ @@ -1320,6 +1327,14 @@ "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": { + "default": { + "description": "Default value", + "type": [ + "number", + "null" + ], + "format": "double" + }, "description": { "description": "Human-readable description", "type": [ @@ -2227,6 +2242,13 @@ "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": { + "default": { + "description": "Default value", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Human-readable description", "type": [ diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 16f09f61..d834f718 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -183,11 +183,9 @@ impl ElicitationServer { )])) } } - ElicitationAction::Decline | ElicitationAction::Cancel => { - Ok(CallToolResult::success(vec![Content::text( - "Email reply cancelled".to_string(), - )])) - } + ElicitationAction::Decline | ElicitationAction::Cancel => Ok(CallToolResult::success( + vec![Content::text("Email reply cancelled".to_string())], + )), } } }