diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 2b63f66f..5909f1d7 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -79,7 +79,7 @@ default = ["base64", "macros", "server"] client = ["dep:tokio-stream"] server = ["transport-async-rw", "dep:schemars"] macros = ["dep:rmcp-macros", "dep:pastey"] -elicitation = [] +elicitation = ["dep:url"] # reqwest http client __reqwest = ["dep:reqwest"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 15b1c0c0..0f68dbaf 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -60,6 +60,10 @@ impl Service for H { ServerNotification::PromptListChangedNotification(_notification_no_param) => { self.on_prompt_list_changed(context).await } + ServerNotification::ElicitationCompletionNotification(notification) => { + self.on_url_elicitation_notification_complete(notification.params, context) + .await + } ServerNotification::CustomNotification(notification) => { self.on_custom_notification(notification, context).await } @@ -114,6 +118,44 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { /// # Default Behavior /// The default implementation automatically declines all elicitation requests. /// Real clients should override this to provide user interaction. + /// + /// # Example + /// ```rust,ignore + /// use rmcp::model::CreateElicitationRequestParam; + /// use rmcp::{ + /// model::ErrorData as McpError, + /// model::*, + /// service::{NotificationContext, RequestContext, RoleClient, Service, ServiceRole}, + /// }; + /// use rmcp::ClientHandler; + /// + /// impl ClientHandler for MyClient { + /// async fn create_elicitation( + /// &self, + /// request: CreateElicitationRequestParam, + /// context: RequestContext, + /// ) -> Result { + /// match request { + /// CreateElicitationRequestParam::FormElicitationParam {message, requested_schema} => { + /// // Display message to user and collect input according to requested_schema + /// let user_input = get_user_input(message, requested_schema).await?; + /// Ok(CreateElicitationResult { + /// action: ElicitationAction::Accept, + /// content: Some(user_input), + /// }) + /// } + /// CreateElicitationRequestParam::UrlElicitationParam {message, url, elicitation_id} => { + /// // Open URL in browser for user to complete elicitation + /// open_url_in_browser(url).await?; + /// Ok(CreateElicitationResult { + /// action: ElicitationAction::Accept, + /// content: None, + /// }) + /// } + /// } + /// } + /// } + /// ``` fn create_elicitation( &self, request: CreateElicitationRequestParam, @@ -187,6 +229,14 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { ) -> impl Future + Send + '_ { std::future::ready(()) } + + fn on_url_elicitation_notification_complete( + &self, + params: ElicitationResponseNotificationParam, + context: NotificationContext, + ) -> impl Future + Send + '_ { + std::future::ready(()) + } fn on_custom_notification( &self, notification: CustomNotification, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 36cd8fb9..27eba4b5 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -453,6 +453,7 @@ impl ErrorCode { pub const INVALID_PARAMS: Self = Self(-32602); pub const INTERNAL_ERROR: Self = Self(-32603); pub const PARSE_ERROR: Self = Self(-32700); + pub const URL_ELICITATION_REQUIRED: Self = Self(-32042); } /// Error information for JSON-RPC error responses. @@ -504,6 +505,12 @@ impl ErrorData { pub fn internal_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::INTERNAL_ERROR, message, data) } + pub fn url_elicitation_required( + message: impl Into>, + data: Option, + ) -> Self { + Self::new(ErrorCode::URL_ELICITATION_REQUIRED, message, data) + } } /// Represents any JSON-RPC message that can be sent or received. @@ -1447,6 +1454,7 @@ pub type RootsListChangedNotification = NotificationNoParam for CreateElicitationRequestParam { + type Error = serde_json::Error; + + fn try_from( + value: CreateElicitationRequestParamDeserializeHelper, + ) -> Result { + match value { + CreateElicitationRequestParamDeserializeHelper::FormElicitationParam { + message, + requested_schema, + } + | CreateElicitationRequestParamDeserializeHelper::FormElicitationParamBackwardsCompat { + message, + requested_schema, + } => Ok(CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + }), + CreateElicitationRequestParamDeserializeHelper::UrlElicitationParam { + message, + url, + elicitation_id, + } => Ok(CreateElicitationRequestParam::UrlElicitationParam { + message, + url, + elicitation_id, + }), + } + } +} + /// Parameters for creating an elicitation request to gather user input. /// /// This structure contains everything needed to request interactive input from a user: @@ -1473,11 +1536,11 @@ pub enum ElicitationAction { /// - A type-safe schema defining the expected structure of the response /// /// # Example -/// +/// 1. Form-based elicitation request /// ```rust /// use rmcp::model::*; /// -/// let params = CreateElicitationRequestParam { +/// let params = CreateElicitationRequestParam::FormElicitationParam { /// message: "Please provide your email".to_string(), /// requested_schema: ElicitationSchema::builder() /// .required_email("email") @@ -1485,19 +1548,47 @@ pub enum ElicitationAction { /// .unwrap(), /// }; /// ``` +/// 2. URL-based elicitation request +/// ```rust +/// use rmcp::model::*; +/// let params = CreateElicitationRequestParam::UrlElicitationParam { +/// message: "Please provide your feedback at the following URL".to_string(), +/// url: "https://example.com/feedback".to_string(), +/// elicitation_id: "unique-id-123".to_string(), +/// }; +/// ``` #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] +#[serde( + tag = "mode", + try_from = "CreateElicitationRequestParamDeserializeHelper" +)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct CreateElicitationRequestParam { - /// Human-readable message explaining what input is needed from the user. - /// This should be clear and provide sufficient context for the user to understand - /// what information they need to provide. - pub message: String, - - /// Type-safe schema defining the expected structure and validation rules for the user's response. - /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects - /// with primitive-typed properties. - pub requested_schema: ElicitationSchema, +pub enum CreateElicitationRequestParam { + #[serde(rename = "form", rename_all = "camelCase")] + FormElicitationParam { + /// Human-readable message explaining what input is needed from the user. + /// This should be clear and provide sufficient context for the user to understand + /// what information they need to provide. + message: String, + + /// Type-safe schema defining the expected structure and validation rules for the user's response. + /// This enforces the MCP 2025-06-18 specification that elicitation schemas must be objects + /// with primitive-typed properties. + requested_schema: ElicitationSchema, + }, + #[serde(rename = "url", rename_all = "camelCase")] + UrlElicitationParam { + /// Human-readable message explaining what input is needed from the user. + /// This should be clear and provide sufficient context for the user to understand + /// what information they need to provide. + message: String, + + /// The URL where the user can provide the requested information. + /// The client should direct the user to this URL to complete the elicitation. + url: String, + /// The unique identifier for this elicitation request. + elicitation_id: String, + }, } /// The result returned by a client in response to an elicitation request. @@ -1522,6 +1613,18 @@ pub struct CreateElicitationResult { pub type CreateElicitationRequest = Request; +/// Notification parameters for an url elicitation completion notification. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ElicitationResponseNotificationParam { + pub elicitation_id: String, +} + +/// Notification sent when an url elicitation process is completed. +pub type ElicitationCompletionNotification = + Notification; + // ============================================================================= // TOOL EXECUTION RESULTS // ============================================================================= @@ -1945,6 +2048,7 @@ ts_union!( | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification + | ElicitationCompletionNotification | CustomNotification; ); @@ -2446,4 +2550,124 @@ mod tests { assert_eq!(json["serverInfo"]["icons"][0]["sizes"][0], "48x48"); assert_eq!(json["serverInfo"]["websiteUrl"], "https://docs.example.com"); } + + #[test] + fn test_elicitation_deserialization_untagged() { + // Test deserialization without the "type" field (should default to FormElicitationParam) + let json_data_without_tag = json!({ + "message": "Please provide more details.", + "requestedSchema": { + "title": "User Details", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"] + } + }); + let elicitation: CreateElicitationRequestParam = + serde_json::from_value(json_data_without_tag).expect("Deserialization failed"); + if let CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + } = elicitation + { + assert_eq!(message, "Please provide more details."); + assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); + assert_eq!(requested_schema.type_, ObjectTypeConst); + } else { + panic!("Expected FormElicitationParam"); + } + } + + #[test] + fn test_elicitation_deserialization() { + let json_data_form = json!({ + "mode": "form", + "message": "Please provide more details.", + "requestedSchema": { + "title": "User Details", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"] + } + }); + let elicitation_form: CreateElicitationRequestParam = + serde_json::from_value(json_data_form).expect("Deserialization failed"); + if let CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + } = elicitation_form + { + assert_eq!(message, "Please provide more details."); + assert_eq!(requested_schema.title, Some(Cow::from("User Details"))); + assert_eq!(requested_schema.type_, ObjectTypeConst); + } else { + panic!("Expected FormElicitationParam"); + } + + let json_data_url = json!({ + "mode": "url", + "message": "Please fill out the form at the following URL.", + "url": "https://example.com/form", + "elicitationId": "elicitation-123" + }); + let elicitation_url: CreateElicitationRequestParam = + serde_json::from_value(json_data_url).expect("Deserialization failed"); + if let CreateElicitationRequestParam::UrlElicitationParam { + message, + url, + elicitation_id, + } = elicitation_url + { + assert_eq!(message, "Please fill out the form at the following URL."); + assert_eq!(url, "https://example.com/form"); + assert_eq!(elicitation_id, "elicitation-123"); + } else { + panic!("Expected UrlElicitationParam"); + } + } + + #[test] + fn test_elicitation_serialization() { + let form_elicitation = CreateElicitationRequestParam::FormElicitationParam { + message: "Please provide more details.".to_string(), + requested_schema: ElicitationSchema::builder() + .title("User Details") + .string_property("name", |s| s) + .build() + .expect("Valid schema"), + }; + let json_form = serde_json::to_value(&form_elicitation).expect("Serialization failed"); + let expected_form_json = json!({ + "mode": "form", + "message": "Please provide more details.", + "requestedSchema": { + "title":"User Details", + "type":"object", + "properties":{ + "name": { "type": "string" }, + }, + } + }); + assert_eq!(json_form, expected_form_json); + + let url_elicitation = CreateElicitationRequestParam::UrlElicitationParam { + message: "Please fill out the form at the following URL.".to_string(), + url: "https://example.com/form".to_string(), + elicitation_id: "elicitation-123".to_string(), + }; + let json_url = serde_json::to_value(&url_elicitation).expect("Serialization failed"); + let expected_url_json = json!({ + "mode": "url", + "message": "Please fill out the form at the following URL.", + "url": "https://example.com/form", + "elicitationId": "elicitation-123" + }); + assert_eq!(json_url, expected_url_json); + } } diff --git a/crates/rmcp/src/model/capabilities.rs b/crates/rmcp/src/model/capabilities.rs index 1740b3ee..5f1bb372 100644 --- a/crates/rmcp/src/model/capabilities.rs +++ b/crates/rmcp/src/model/capabilities.rs @@ -61,13 +61,11 @@ pub type TaskRequestMap = BTreeMap; /// Capability for handling elicitation requests from servers. /// -/// Elicitation allows servers to request interactive input from users during tool execution. -/// This capability indicates that a client can handle elicitation requests and present -/// appropriate UI to users for collecting the requested information. +/// Capability for form mode elicitation. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ElicitationCapability { +pub struct FormElicitationCapability { /// Whether the client supports JSON Schema validation for elicitation responses. /// When true, the client will validate user input against the requested_schema /// before sending the response back to the server. @@ -75,6 +73,26 @@ pub struct ElicitationCapability { pub schema_validation: Option, } +/// Capability for URL mode elicitation. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct UrlElicitationCapability {} + +/// Elicitation allows servers to request interactive input from users during tool execution. +/// This capability indicates that a client can handle elicitation requests and present +/// appropriate UI to users for collecting the requested information. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ElicitationCapability { + /// Whether client supports form-based elicitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub form: Option, + /// Whether client supports URL-based elicitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + /// /// # Builder /// ```rust @@ -316,12 +334,14 @@ impl impl ClientCapabilitiesBuilder> { - /// Enable JSON Schema validation for elicitation responses. + /// Enable JSON Schema validation for elicitation responses in form mode. /// When enabled, the client will validate user input against the requested_schema /// before sending responses back to the server. pub fn enable_elicitation_schema_validation(mut self) -> Self { if let Some(c) = self.elicitation.as_mut() { - c.schema_validation = Some(true); + c.form = Some(FormElicitationCapability { + schema_validation: Some(true), + }); } self } diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index acda3900..774dc2fd 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -143,6 +143,7 @@ variant_extension! { ResourceListChangedNotification ToolListChangedNotification PromptListChangedNotification + ElicitationCompletionNotification CustomNotification } } diff --git a/crates/rmcp/src/service/server.rs b/crates/rmcp/src/service/server.rs index 82a7e7d8..b462c6d1 100644 --- a/crates/rmcp/src/service/server.rs +++ b/crates/rmcp/src/service/server.rs @@ -1,11 +1,16 @@ use std::borrow::Cow; +#[cfg(feature = "elicitation")] +use std::collections::HashSet; use thiserror::Error; +#[cfg(feature = "elicitation")] +use url::Url; use super::*; #[cfg(feature = "elicitation")] use crate::model::{ CreateElicitationRequest, CreateElicitationRequestParam, CreateElicitationResult, + ElicitationAction, ElicitationCompletionNotification, ElicitationResponseNotificationParam, }; use crate::{ model::{ @@ -405,6 +410,8 @@ impl Peer { method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult); #[cfg(feature = "elicitation")] method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult); + #[cfg(feature = "elicitation")] + method!(peer_not notify_url_elicitation_completed ElicitationCompletionNotification(ElicitationResponseNotificationParam)); method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam)); method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam)); @@ -509,6 +516,12 @@ macro_rules! elicit_safe { }; } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ElicitationMode { + Form, + Url, +} + #[cfg(feature = "elicitation")] impl Peer { /// Check if the client supports elicitation capability @@ -516,11 +529,27 @@ impl Peer { /// Returns true if the client declared elicitation capability during initialization, /// false otherwise. According to MCP 2025-06-18 specification, clients that support /// elicitation MUST declare the capability during initialization. - pub fn supports_elicitation(&self) -> bool { + pub fn supported_elicitation_modes(&self) -> HashSet { if let Some(client_info) = self.peer_info() { - client_info.capabilities.elicitation.is_some() + if let Some(elicit_capability) = &client_info.capabilities.elicitation { + let mut modes = HashSet::new(); + // Backward compatibility: if neither form nor url is specified, assume form + if elicit_capability.form.is_none() && elicit_capability.url.is_none() { + modes.insert(ElicitationMode::Form); + } else { + if elicit_capability.form.is_some() { + modes.insert(ElicitationMode::Form); + } + if elicit_capability.url.is_some() { + modes.insert(ElicitationMode::Url); + } + } + modes + } else { + HashSet::new() + } } else { - false + HashSet::new() } } @@ -671,8 +700,11 @@ impl Peer { where T: ElicitationSafe + for<'de> serde::Deserialize<'de>, { - // Check if client supports elicitation capability - if !self.supports_elicitation() { + // Check if client supports form elicitation capability + if !self + .supported_elicitation_modes() + .contains(&ElicitationMode::Form) + { return Err(ElicitationError::CapabilityNotSupported); } @@ -690,7 +722,7 @@ impl Peer { let response = self .create_elicitation_with_timeout( - CreateElicitationRequestParam { + CreateElicitationRequestParam::FormElicitationParam { message: message.into(), requested_schema: schema, }, @@ -713,4 +745,122 @@ impl Peer { crate::model::ElicitationAction::Cancel => Err(ElicitationError::UserCancelled), } } + + /// Request the user to visit a URL and confirm completion. + /// + /// This method sends a URL elicitation request to the client, prompting the user + /// to visit the specified URL and confirm completion. It returns the user's action + /// (accept/decline/cancel) without any additional data. + /// **Requires the `elicitation` feature to be enabled.** + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `url` - The URL the user is requested to visit + /// * `elicitation_id` - A unique identifier for this elicitation request + /// # Returns + /// * `Ok(action)` indicating the user's response action + /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// # Example + /// ```rust,no_run + /// # use rmcp::*; + /// # use rmcp::model::ElicitationAction; + /// # use url::Url; + /// + /// async fn example(peer: Peer) -> Result<(), Box> { + /// let elicit_result = peer.elicit_url("Please visit the following URL to complete the action", + /// Url::parse("https://example.com/complete_action")?, "elicit_123").await?; + /// match elicit_result { + /// ElicitationAction::Accept => { + /// println!("User accepted and confirmed completion"); + /// } + /// ElicitationAction::Decline => { + /// println!("User declined the request"); + /// } + /// ElicitationAction::Cancel => { + /// println!("User cancelled/dismissed the request"); + /// } + /// } + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "elicitation")] + pub async fn elicit_url( + &self, + message: impl Into, + url: impl Into, + elicitation_id: impl Into, + ) -> Result { + self.elicit_url_with_timeout(message, url, elicitation_id, None) + .await + } + + /// Request the user to visit a URL and confirm completion. + /// + /// Same as `elicit_url()` but allows specifying a custom timeout for the request. + /// + /// # Arguments + /// * `message` - The prompt message for the user + /// * `url` - The URL the user is requested to visit + /// * `elicitation_id` - A unique identifier for this elicitation request + /// * `timeout` - Optional timeout duration. If None, uses default timeout behavior + /// # Returns + /// * `Ok(action)` indicating the user's response action + /// * `Err(ElicitationError::CapabilityNotSupported)` if client does not support elicitation via URL + /// * `Err(ElicitationError::Service(_))` if the underlying service call failed + /// # Example + /// ```rust,no_run + /// # use std::time::Duration; + /// use rmcp::*; + /// # use rmcp::model::ElicitationAction; + /// # use url::Url; + /// + /// async fn example(peer: Peer) -> Result<(), Box> { + /// let elicit_result = peer.elicit_url_with_timeout("Please visit the following URL to complete the action", + /// Url::parse("https://example.com/complete_action")?, + /// "elicit_123", + /// Some(Duration::from_secs(30))).await?; + /// match elicit_result { + /// ElicitationAction::Accept => { + /// println!("User accepted and confirmed completion"); + /// } + /// ElicitationAction::Decline => { + /// println!("User declined the request"); + /// } + /// ElicitationAction::Cancel => { + /// println!("User cancelled/dismissed the request"); + /// } + /// } + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "elicitation")] + pub async fn elicit_url_with_timeout( + &self, + message: impl Into, + url: impl Into, + elicitation_id: impl Into, + timeout: Option, + ) -> Result { + // Check if client supports url elicitation + if !self + .supported_elicitation_modes() + .contains(&ElicitationMode::Url) + { + return Err(ElicitationError::CapabilityNotSupported); + } + + let action = self + .create_elicitation_with_timeout( + CreateElicitationRequestParam::UrlElicitationParam { + message: message.into(), + url: url.into().to_string(), + elicitation_id: elicitation_id.into(), + }, + timeout, + ) + .await? + .action; + Ok(action) + } } diff --git a/crates/rmcp/tests/test_elicitation.rs b/crates/rmcp/tests/test_elicitation.rs index 87c6706c..244350a9 100644 --- a/crates/rmcp/tests/test_elicitation.rs +++ b/crates/rmcp/tests/test_elicitation.rs @@ -44,7 +44,7 @@ async fn test_elicitation_request_param_serialization() { .build() .unwrap(); - let request_param = CreateElicitationRequestParam { + let request_param = CreateElicitationRequestParam::FormElicitationParam { message: "Please provide your email address".to_string(), requested_schema: schema, }; @@ -52,6 +52,7 @@ async fn test_elicitation_request_param_serialization() { // Test serialization let json = serde_json::to_value(&request_param).unwrap(); let expected = json!({ + "mode": "form", "message": "Please provide your email address", "requestedSchema": { "type": "object", @@ -69,11 +70,22 @@ async fn test_elicitation_request_param_serialization() { // Test deserialization let deserialized: CreateElicitationRequestParam = serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.message, request_param.message); - assert_eq!( - deserialized.requested_schema, - request_param.requested_schema - ); + match (&deserialized, &request_param) { + ( + CreateElicitationRequestParam::FormElicitationParam { + message: msg1, + requested_schema: schema1, + }, + CreateElicitationRequestParam::FormElicitationParam { + message: msg2, + requested_schema: schema2, + }, + ) => { + assert_eq!(msg1, msg2); + assert_eq!(schema1, schema2); + } + _ => panic!("Expected FormElicitationParam variant"), + } } /// Test CreateElicitationResult structure with different action types @@ -128,7 +140,7 @@ async fn test_elicitation_json_rpc_protocol() { id: RequestId::Number(1), request: CreateElicitationRequest { method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParam { + params: CreateElicitationRequestParam::FormElicitationParam { message: "Do you want to continue?".to_string(), requested_schema: schema, }, @@ -147,10 +159,15 @@ async fn test_elicitation_json_rpc_protocol() { let deserialized: JsonRpcRequest = serde_json::from_value(json).unwrap(); assert_eq!(deserialized.id, RequestId::Number(1)); - assert_eq!( - deserialized.request.params.message, - "Do you want to continue?" - ); + match &deserialized.request.params { + CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema: _, + } => { + assert_eq!(message, "Do you want to continue?"); + } + _ => panic!("Expected FormElicitationParam variant"), + } } /// Test elicitation action types and their expected behavior @@ -212,7 +229,7 @@ async fn test_elicitation_spec_compliance() { #[tokio::test] async fn test_elicitation_error_handling() { // Test minimal schema handling (empty properties is technically valid) - let minimal_schema_request = CreateElicitationRequestParam { + let minimal_schema_request = CreateElicitationRequestParam::FormElicitationParam { message: "Test message".to_string(), requested_schema: ElicitationSchema::builder().build().unwrap(), }; @@ -221,7 +238,7 @@ async fn test_elicitation_error_handling() { let _json = serde_json::to_value(&minimal_schema_request).unwrap(); // Test empty message - let empty_message_request = CreateElicitationRequestParam { + let empty_message_request = CreateElicitationRequestParam::FormElicitationParam { message: "".to_string(), requested_schema: ElicitationSchema::builder() .property("value", PrimitiveSchema::String(StringSchema::new())) @@ -246,7 +263,7 @@ async fn test_elicitation_performance() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParam::FormElicitationParam { message: "Performance test message".to_string(), requested_schema: schema, }; @@ -281,19 +298,25 @@ async fn test_elicitation_capabilities() { // Test basic elicitation capability let mut elicitation_cap = ElicitationCapability::default(); - assert_eq!(elicitation_cap.schema_validation, None); + assert_eq!(elicitation_cap.form, None); + assert_eq!(elicitation_cap.url, None); // Test with schema validation enabled - elicitation_cap.schema_validation = Some(true); + elicitation_cap.form = Some(FormElicitationCapability { + schema_validation: Some(true), + }); // Test serialization let json = serde_json::to_value(&elicitation_cap).unwrap(); - let expected = json!({"schemaValidation": true}); + let expected = json!({"form":{"schemaValidation": true}}); assert_eq!(json, expected); // Test deserialization let deserialized: ElicitationCapability = serde_json::from_value(expected).unwrap(); - assert_eq!(deserialized.schema_validation, Some(true)); + assert_eq!( + deserialized.form.as_ref().unwrap().schema_validation, + Some(true) + ); // Test ClientCapabilities builder with elicitation let client_caps = ClientCapabilities::builder() @@ -303,14 +326,21 @@ async fn test_elicitation_capabilities() { assert!(client_caps.elicitation.is_some()); assert_eq!( - client_caps.elicitation.as_ref().unwrap().schema_validation, + client_caps + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, Some(true) ); // Test full client capabilities serialization let json = serde_json::to_value(&client_caps).unwrap(); assert!( - json["elicitation"]["schemaValidation"] + json["elicitation"]["form"]["schemaValidation"] .as_bool() .unwrap_or(false) ); @@ -370,7 +400,7 @@ async fn test_elicitation_convenience_methods() { ); // Test that CreateElicitationRequestParam can be created with type-safe schemas - let confirmation_request = CreateElicitationRequestParam { + let confirmation_request = CreateElicitationRequestParam::FormElicitationParam { message: "Test confirmation".to_string(), requested_schema: ElicitationSchema::builder() .property( @@ -412,7 +442,7 @@ async fn test_elicitation_structured_schemas() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParam::FormElicitationParam { message: "Please provide your user information".to_string(), requested_schema: schema, }; @@ -421,42 +451,30 @@ async fn test_elicitation_structured_schemas() { let json = serde_json::to_value(&request).unwrap(); let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.message, "Please provide your user information"); - assert_eq!(deserialized.requested_schema.properties.len(), 5); - assert!( - deserialized - .requested_schema - .properties - .contains_key("name") - ); - assert!( - deserialized - .requested_schema - .properties - .contains_key("email") - ); - assert!(deserialized.requested_schema.properties.contains_key("age")); - assert!( - deserialized - .requested_schema - .properties - .contains_key("newsletter") - ); - assert!( - deserialized - .requested_schema - .properties - .contains_key("country") - ); - assert_eq!( - deserialized.requested_schema.required, - Some(vec![ - "name".to_string(), - "email".to_string(), - "age".to_string(), - "country".to_string() - ]) - ); + match deserialized { + CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 5); + assert!(requested_schema.properties.contains_key("name")); + assert!(requested_schema.properties.contains_key("email")); + assert!(requested_schema.properties.contains_key("age")); + assert!(requested_schema.properties.contains_key("newsletter")); + assert!(requested_schema.properties.contains_key("country")); + assert_eq!( + requested_schema.required, + Some(vec![ + "name".to_string(), + "email".to_string(), + "age".to_string(), + "country".to_string() + ]) + ); + } + _ => panic!("Expected FormElicitationParam variant"), + } } // Typed elicitation tests using the API with schemars @@ -654,7 +672,7 @@ async fn test_elicitation_multi_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParam::FormElicitationParam { message: "Please provide your user information".to_string(), requested_schema: schema, }; @@ -663,58 +681,55 @@ async fn test_elicitation_multi_select_enum() { let json = serde_json::to_value(&request).unwrap(); let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.message, "Please provide your user information"); - assert_eq!(deserialized.requested_schema.properties.len(), 1); - assert!( - deserialized - .requested_schema - .properties - .contains_key("choices") - ); - assert_eq!( - deserialized.requested_schema.required, - Some(vec!["choices".to_string()]) - ); - - assert!(matches!( - deserialized - .requested_schema - .properties - .get("choices") - .unwrap(), - PrimitiveSchema::Enum(EnumSchema::Multi(_)) - )); - - if let Some(PrimitiveSchema::Enum(schema)) = - deserialized.requested_schema.properties.get("choices") - { - assert_eq!( - schema, - &EnumSchema::Multi(MultiSelectEnumSchema::Titled(TitledMultiSelectEnumSchema { - type_: ArrayTypeConst, - title: None, - description: None, - min_items: Some(1), - max_items: Some(2), - items: TitledItems { - any_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() + match deserialized { + CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("choices")); + assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); + + assert!(matches!( + requested_schema.properties.get("choices").unwrap(), + PrimitiveSchema::Enum(EnumSchema::Multi(_)) + )); + + if let Some(PrimitiveSchema::Enum(schema)) = requested_schema.properties.get("choices") + { + assert_eq!( + schema, + &EnumSchema::Multi(MultiSelectEnumSchema::Titled( + TitledMultiSelectEnumSchema { + type_: ArrayTypeConst, + title: None, + description: None, + min_items: Some(1), + max_items: Some(2), + items: TitledItems { + any_of: vec![ + ConstTitle { + const_: "A".to_string(), + title: "A name".to_string() + }, + ConstTitle { + const_: "B".to_string(), + title: "B name".to_string() + }, + ConstTitle { + const_: "C".to_string(), + title: "C name".to_string() + } + ], + }, + default: None } - ], - }, - default: None - })) - ) + )) + ) + } + } + _ => panic!("Expected FormElicitationParam variant"), } } @@ -735,7 +750,7 @@ async fn test_elicitation_single_select_enum() { .build() .unwrap(); - let request = CreateElicitationRequestParam { + let request = CreateElicitationRequestParam::FormElicitationParam { message: "Please provide your user information".to_string(), requested_schema: schema, }; @@ -743,55 +758,51 @@ async fn test_elicitation_single_select_enum() { // Test that complex schemas serialize/deserialize correctly let json = serde_json::to_value(&request).unwrap(); let deserialized: CreateElicitationRequestParam = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.message, "Please provide your user information"); - assert_eq!(deserialized.requested_schema.properties.len(), 1); - assert!( - deserialized - .requested_schema - .properties - .contains_key("choices") - ); - assert_eq!( - deserialized.requested_schema.required, - Some(vec!["choices".to_string()]) - ); - assert!(matches!( - deserialized - .requested_schema - .properties - .get("choices") - .unwrap(), - PrimitiveSchema::Enum(EnumSchema::Single(_)) - )); - - if let Some(PrimitiveSchema::Enum(schema)) = - deserialized.requested_schema.properties.get("choices") - { - assert_eq!( - schema, - &EnumSchema::Single(SingleSelectEnumSchema::Titled( - TitledSingleSelectEnumSchema { - type_: StringTypeConst, - title: None, - description: None, - one_of: vec![ - ConstTitle { - const_: "A".to_string(), - title: "A name".to_string() - }, - ConstTitle { - const_: "B".to_string(), - title: "B name".to_string() - }, - ConstTitle { - const_: "C".to_string(), - title: "C name".to_string() + + match deserialized { + CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + } => { + assert_eq!(message, "Please provide your user information"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("choices")); + assert_eq!(requested_schema.required, Some(vec!["choices".to_string()])); + assert!(matches!( + requested_schema.properties.get("choices").unwrap(), + PrimitiveSchema::Enum(EnumSchema::Single(_)) + )); + + if let Some(PrimitiveSchema::Enum(schema)) = requested_schema.properties.get("choices") + { + assert_eq!( + schema, + &EnumSchema::Single(SingleSelectEnumSchema::Titled( + TitledSingleSelectEnumSchema { + type_: StringTypeConst, + title: None, + description: None, + one_of: vec![ + ConstTitle { + const_: "A".to_string(), + title: "A name".to_string() + }, + ConstTitle { + const_: "B".to_string(), + title: "B name".to_string() + }, + ConstTitle { + const_: "C".to_string(), + title: "C name".to_string() + } + ], + default: None } - ], - default: None - } - )) - ) + )) + ) + } + } + _ => panic!("Expected FormElicitationParam variant"), } } @@ -816,7 +827,7 @@ async fn test_elicitation_direction_server_to_client() { .build() .unwrap(); - let elicitation_request = CreateElicitationRequestParam { + let elicitation_request = CreateElicitationRequestParam::FormElicitationParam { message: "Please enter your name".to_string(), requested_schema: schema, }; @@ -868,7 +879,7 @@ async fn test_elicitation_json_rpc_direction() { let server_request = ServerJsonRpcMessage::request( ServerRequest::CreateElicitationRequest(CreateElicitationRequest { method: ElicitationCreateRequestMethod, - params: CreateElicitationRequestParam { + params: CreateElicitationRequestParam::FormElicitationParam { message: "Do you want to continue?".to_string(), requested_schema: schema, }, @@ -973,32 +984,54 @@ async fn test_elicitation_result_in_client_result() { async fn test_elicitation_capability_structure() { // Test default ElicitationCapability let default_cap = ElicitationCapability::default(); - assert!(default_cap.schema_validation.is_none()); + assert!(default_cap.form.is_none()); + assert!(default_cap.url.is_none()); // Test ElicitationCapability with schema validation enabled let cap_with_validation = ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }; - assert_eq!(cap_with_validation.schema_validation, Some(true)); + assert_eq!( + cap_with_validation.form.as_ref().unwrap().schema_validation, + Some(true) + ); // Test ElicitationCapability with schema validation disabled let cap_without_validation = ElicitationCapability { - schema_validation: Some(false), + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: None, }; - assert_eq!(cap_without_validation.schema_validation, Some(false)); + assert_eq!( + cap_without_validation + .form + .as_ref() + .unwrap() + .schema_validation, + Some(false) + ); // Test JSON serialization let json = serde_json::to_value(&cap_with_validation).unwrap(); assert_eq!( json, serde_json::json!({ - "schemaValidation": true + "form": { + "schemaValidation": true + } }) ); // Test JSON deserialization let deserialized: ElicitationCapability = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized.schema_validation, Some(true)); + assert_eq!( + deserialized.form.as_ref().unwrap().schema_validation, + Some(true) + ); } /// Test ClientCapabilities with elicitation capability @@ -1007,7 +1040,10 @@ async fn test_client_capabilities_with_elicitation() { // Test ClientCapabilities with elicitation capability let capabilities = ClientCapabilities { elicitation: Some(ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }), ..Default::default() }; @@ -1015,14 +1051,21 @@ async fn test_client_capabilities_with_elicitation() { // Verify elicitation capability is present assert!(capabilities.elicitation.is_some()); assert_eq!( - capabilities.elicitation.as_ref().unwrap().schema_validation, + capabilities + .elicitation + .as_ref() + .unwrap() + .form + .as_ref() + .unwrap() + .schema_validation, Some(true) ); // Test JSON serialization let json = serde_json::to_value(&capabilities).unwrap(); assert!( - json["elicitation"]["schemaValidation"] + json["elicitation"]["form"]["schemaValidation"] .as_bool() .unwrap_or(false) ); @@ -1044,7 +1087,10 @@ async fn test_initialize_request_with_elicitation() { protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: Some(ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }), ..Default::default() }, @@ -1065,6 +1111,9 @@ async fn test_initialize_request_with_elicitation() { .elicitation .as_ref() .unwrap() + .form + .as_ref() + .unwrap() .schema_validation, Some(true) ); @@ -1072,7 +1121,7 @@ async fn test_initialize_request_with_elicitation() { // Test JSON serialization let json = serde_json::to_value(&init_param).unwrap(); assert!( - json["capabilities"]["elicitation"]["schemaValidation"] + json["capabilities"]["elicitation"]["form"]["schemaValidation"] .as_bool() .unwrap_or(false) ); @@ -1088,7 +1137,10 @@ async fn test_capability_checking_logic() { protocol_version: ProtocolVersion::LATEST, capabilities: ClientCapabilities { elicitation: Some(ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }), ..Default::default() }, @@ -1216,37 +1268,47 @@ async fn test_elicitation_capability_serialization() { // Test capability with schema validation enabled let cap_with_validation = ElicitationCapability { - schema_validation: Some(true), + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, }; let json = serde_json::to_value(&cap_with_validation).unwrap(); assert_eq!( json, serde_json::json!({ - "schemaValidation": true + "form": { + "schemaValidation": true + } }) ); // Test capability with schema validation disabled let cap_without_validation = ElicitationCapability { - schema_validation: Some(false), + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: None, }; let json = serde_json::to_value(&cap_without_validation).unwrap(); assert_eq!( json, serde_json::json!({ - "schemaValidation": false + "form": { + "schemaValidation": false + } }) ); // Test deserialization let deserialized: ElicitationCapability = serde_json::from_value(serde_json::json!({ - "schemaValidation": true + "form":{"schemaValidation": true} })) .unwrap(); - assert_eq!(deserialized.schema_validation, Some(true)); + assert_eq!(deserialized.form.unwrap().schema_validation, Some(true)); } /// Test ClientCapabilities builder with elicitation capability methods @@ -1258,7 +1320,7 @@ async fn test_client_capabilities_elicitation_builder() { let caps = ClientCapabilities::builder().enable_elicitation().build(); assert!(caps.elicitation.is_some()); - assert_eq!(caps.elicitation.as_ref().unwrap().schema_validation, None); + assert_eq!(caps.elicitation.as_ref().unwrap().form, None); // Test enabling elicitation with schema validation let caps_with_validation = ClientCapabilities::builder() @@ -1272,13 +1334,19 @@ async fn test_client_capabilities_elicitation_builder() { .elicitation .as_ref() .unwrap() + .form + .as_ref() + .unwrap() .schema_validation, Some(true) ); // Test enabling elicitation with custom capability let custom_elicitation = ElicitationCapability { - schema_validation: Some(false), + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: None, }; let caps_custom = ClientCapabilities::builder() @@ -1308,7 +1376,7 @@ async fn test_create_elicitation_with_timeout_basic() { .build() .unwrap(); - let _params = CreateElicitationRequestParam { + let _params = CreateElicitationRequestParam::FormElicitationParam { message: "Enter your details".to_string(), requested_schema: schema, }; @@ -1769,3 +1837,414 @@ async fn test_required_typed_property_methods() { assert!(required.contains(&"age".to_string())); assert!(required.contains(&"active".to_string())); } + +// ============================================================================= +// URL ELICITATION TESTS +// ============================================================================= + +/// Test URL elicitation request parameter serialization/deserialization +#[tokio::test] +async fn test_url_elicitation_request_param_serialization() { + let request_param = CreateElicitationRequestParam::UrlElicitationParam { + message: "Please visit the following URL to complete verification".to_string(), + url: "https://example.com/verify".to_string(), + elicitation_id: "elicit-123".to_string(), + }; + + // Test serialization + let json = serde_json::to_value(&request_param).unwrap(); + let expected = json!({ + "mode": "url", + "message": "Please visit the following URL to complete verification", + "url": "https://example.com/verify", + "elicitationId": "elicit-123" + }); + + assert_eq!(json, expected); + + // Test deserialization + let deserialized: CreateElicitationRequestParam = serde_json::from_value(expected).unwrap(); + match deserialized { + CreateElicitationRequestParam::UrlElicitationParam { + message, + url, + elicitation_id, + } => { + assert_eq!( + message, + "Please visit the following URL to complete verification" + ); + assert_eq!(url, "https://example.com/verify"); + assert_eq!(elicitation_id, "elicit-123"); + } + _ => panic!("Expected UrlElicitationParam variant"), + } +} + +/// Test URL elicitation request in JSON-RPC protocol +#[tokio::test] +async fn test_url_elicitation_json_rpc_protocol() { + // Create a complete JSON-RPC request for URL elicitation + let request = JsonRpcRequest { + jsonrpc: JsonRpcVersion2_0, + id: RequestId::Number(1), + request: CreateElicitationRequest { + method: ElicitationCreateRequestMethod, + params: CreateElicitationRequestParam::UrlElicitationParam { + message: "Please authorize this action at the following URL".to_string(), + url: "https://auth.example.com/authorize/abc123".to_string(), + elicitation_id: "auth-request-456".to_string(), + }, + extensions: Default::default(), + }, + }; + + // Test serialization of complete request + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["jsonrpc"], "2.0"); + assert_eq!(json["id"], 1); + assert_eq!(json["method"], "elicitation/create"); + assert_eq!(json["params"]["mode"], "url"); + assert_eq!( + json["params"]["message"], + "Please authorize this action at the following URL" + ); + assert_eq!( + json["params"]["url"], + "https://auth.example.com/authorize/abc123" + ); + assert_eq!(json["params"]["elicitationId"], "auth-request-456"); + + // Test deserialization + let deserialized: JsonRpcRequest = + serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.id, RequestId::Number(1)); + match &deserialized.request.params { + CreateElicitationRequestParam::UrlElicitationParam { + message, + url, + elicitation_id, + } => { + assert_eq!(message, "Please authorize this action at the following URL"); + assert_eq!(url, "https://auth.example.com/authorize/abc123"); + assert_eq!(elicitation_id, "auth-request-456"); + } + _ => panic!("Expected UrlElicitationParam variant"), + } +} + +/// Test ElicitationCompletionNotification serialization/deserialization +#[tokio::test] +async fn test_elicitation_completion_notification() { + let notification_params = ElicitationResponseNotificationParam { + elicitation_id: "elicit-789".to_string(), + }; + + // Test serialization + let json = serde_json::to_value(¬ification_params).unwrap(); + let expected = json!({ + "elicitationId": "elicit-789" + }); + assert_eq!(json, expected); + + // Test deserialization + let deserialized: ElicitationResponseNotificationParam = + serde_json::from_value(expected).unwrap(); + assert_eq!(deserialized.elicitation_id, "elicit-789"); + + // Test complete notification structure + let notification = ElicitationCompletionNotification { + method: ElicitationCompletionNotificationMethod, + params: notification_params, + extensions: Default::default(), + }; + + let json = serde_json::to_value(¬ification).unwrap(); + assert_eq!(json["method"], "notifications/elicitation/complete"); + assert_eq!(json["params"]["elicitationId"], "elicit-789"); +} + +/// Test UrlElicitationCapability structure and serialization +#[tokio::test] +async fn test_url_elicitation_capability() { + // Test default UrlElicitationCapability + let url_cap = UrlElicitationCapability::default(); + + // Test serialization - should be empty object + let json = serde_json::to_value(&url_cap).unwrap(); + assert_eq!(json, json!({})); + + // Test deserialization + let deserialized: UrlElicitationCapability = serde_json::from_value(json!({})).unwrap(); + assert_eq!(deserialized, url_cap); + + // Test ElicitationCapability with URL mode enabled + let elicitation_cap = ElicitationCapability { + form: None, + url: Some(UrlElicitationCapability::default()), + }; + + let json = serde_json::to_value(&elicitation_cap).unwrap(); + assert_eq!( + json, + json!({ + "url": {} + }) + ); + + // Test ElicitationCapability with both form and URL modes + let both_cap = ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: Some(UrlElicitationCapability::default()), + }; + + let json = serde_json::to_value(&both_cap).unwrap(); + assert_eq!( + json, + json!({ + "form": { + "schemaValidation": true + }, + "url": {} + }) + ); +} + +/// Test backward compatibility: CreateElicitationRequestParam without mode tag +#[tokio::test] +async fn test_elicitation_backward_compatibility_no_mode() { + // JSON without "mode" field should deserialize as FormElicitationParam + let json_without_mode = json!({ + "message": "Please enter your details", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }); + + let deserialized: CreateElicitationRequestParam = + serde_json::from_value(json_without_mode).unwrap(); + + match deserialized { + CreateElicitationRequestParam::FormElicitationParam { + message, + requested_schema, + } => { + assert_eq!(message, "Please enter your details"); + assert_eq!(requested_schema.properties.len(), 1); + assert!(requested_schema.properties.contains_key("name")); + } + _ => panic!("Expected FormElicitationParam for backward compatibility"), + } +} + +/// Test both form and URL elicitation modes in the same test +#[tokio::test] +async fn test_elicitation_both_modes() { + // Form mode + let form_schema = ElicitationSchema::builder() + .required_property("email", PrimitiveSchema::String(StringSchema::email())) + .build() + .unwrap(); + + let form_request = CreateElicitationRequestParam::FormElicitationParam { + message: "Enter email".to_string(), + requested_schema: form_schema, + }; + + let form_json = serde_json::to_value(&form_request).unwrap(); + assert_eq!(form_json["mode"], "form"); + assert!(form_json.get("requestedSchema").is_some()); + assert!(form_json.get("url").is_none()); + + // URL mode + let url_request = CreateElicitationRequestParam::UrlElicitationParam { + message: "Visit URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "id-123".to_string(), + }; + + let url_json = serde_json::to_value(&url_request).unwrap(); + assert_eq!(url_json["mode"], "url"); + assert!(url_json.get("url").is_some()); + assert!(url_json.get("elicitationId").is_some()); + assert!(url_json.get("requestedSchema").is_none()); +} + +/// Test URL_ELICITATION_REQUIRED error code +#[tokio::test] +async fn test_url_elicitation_required_error_code() { + // Test the error code constant + assert_eq!(ErrorCode::URL_ELICITATION_REQUIRED.0, -32042); + + // Test creating error data with URL_ELICITATION_REQUIRED + let error_data = ErrorData::url_elicitation_required( + "URL elicitation is required for this operation", + Some(json!({ + "url": "https://example.com/complete", + "elicitationId": "elicit-999" + })), + ); + + assert_eq!(error_data.code, ErrorCode::URL_ELICITATION_REQUIRED); + assert_eq!( + error_data.message, + "URL elicitation is required for this operation" + ); + assert!(error_data.data.is_some()); + + // Test serialization + let json = serde_json::to_value(&error_data).unwrap(); + assert_eq!(json["code"], -32042); + assert_eq!( + json["message"], + "URL elicitation is required for this operation" + ); + assert_eq!(json["data"]["url"], "https://example.com/complete"); + assert_eq!(json["data"]["elicitationId"], "elicit-999"); +} + +/// Test ClientCapabilities with different elicitation mode combinations +#[tokio::test] +async fn test_client_capabilities_elicitation_modes() { + // Test with form-only capability + let form_only_caps = ClientCapabilities { + elicitation: Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, + }), + ..Default::default() + }; + + let json = serde_json::to_value(&form_only_caps).unwrap(); + assert!(json["elicitation"]["form"].is_object()); + assert!( + json["elicitation"]["url"].is_null() + || !json["elicitation"].as_object().unwrap().contains_key("url") + ); + + // Test with URL-only capability + let url_only_caps = ClientCapabilities { + elicitation: Some(ElicitationCapability { + form: None, + url: Some(UrlElicitationCapability::default()), + }), + ..Default::default() + }; + + let json = serde_json::to_value(&url_only_caps).unwrap(); + assert!(json["elicitation"]["url"].is_object()); + assert!( + json["elicitation"]["form"].is_null() + || !json["elicitation"] + .as_object() + .unwrap() + .contains_key("form") + ); + + // Test with both capabilities + let both_caps = ClientCapabilities { + elicitation: Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(false), + }), + url: Some(UrlElicitationCapability::default()), + }), + ..Default::default() + }; + + let json = serde_json::to_value(&both_caps).unwrap(); + assert!(json["elicitation"]["form"].is_object()); + assert!(json["elicitation"]["url"].is_object()); +} + +/// Test ElicitationCompletionNotification in ServerNotification enum +#[tokio::test] +async fn test_elicitation_completion_in_server_notification() { + let notification_param = ElicitationResponseNotificationParam { + elicitation_id: "notify-123".to_string(), + }; + + let completion_notification = ElicitationCompletionNotification { + method: ElicitationCompletionNotificationMethod, + params: notification_param.clone(), + extensions: Default::default(), + }; + + // Test that it's part of ServerNotification + let server_notification = + ServerNotification::ElicitationCompletionNotification(completion_notification); + + // Test serialization + let json = serde_json::to_value(&server_notification).unwrap(); + assert_eq!(json["method"], "notifications/elicitation/complete"); + assert_eq!(json["params"]["elicitationId"], "notify-123"); + + // Test deserialization + let deserialized: ServerNotification = serde_json::from_value(json).unwrap(); + match deserialized { + ServerNotification::ElicitationCompletionNotification(notif) => { + assert_eq!(notif.params.elicitation_id, "notify-123"); + } + _ => panic!("Expected ElicitationCompletionNotification variant"), + } +} + +/// Test ElicitationAction with URL elicitation workflow +#[tokio::test] +async fn test_url_elicitation_action_workflow() { + // Test Accept action for URL elicitation (user visited URL and confirmed) + let accept_result = CreateElicitationResult { + action: ElicitationAction::Accept, + content: None, // URL elicitation doesn't return content, just confirmation + }; + + let json = serde_json::to_value(&accept_result).unwrap(); + assert_eq!(json["action"], "accept"); + // content should be omitted when None + assert!(json.get("content").is_none() || json["content"].is_null()); + + // Test Decline action for URL elicitation + let decline_result = CreateElicitationResult { + action: ElicitationAction::Decline, + content: None, + }; + + let json = serde_json::to_value(&decline_result).unwrap(); + assert_eq!(json["action"], "decline"); + + // Test Cancel action for URL elicitation + let cancel_result = CreateElicitationResult { + action: ElicitationAction::Cancel, + content: None, + }; + + let json = serde_json::to_value(&cancel_result).unwrap(); + assert_eq!(json["action"], "cancel"); +} + +/// Test method constants for URL elicitation +#[tokio::test] +async fn test_elicitation_method_constants() { + // Test existing methods + assert_eq!(ElicitationCreateRequestMethod::VALUE, "elicitation/create"); + assert_eq!( + ElicitationResponseNotificationMethod::VALUE, + "notifications/elicitation/response" + ); + + // Test new completion notification method + assert_eq!( + ElicitationCompletionNotificationMethod::VALUE, + "notifications/elicitation/complete" + ); +} diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 5ae242a3..e4716d78 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -482,14 +482,29 @@ ] }, "ElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\n\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", "type": "object", "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } ] } } @@ -529,6 +544,19 @@ "message" ] }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, "GetPromptRequestMethod": { "type": "string", "format": "const", @@ -1732,6 +1760,10 @@ "required": [ "uri" ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" } } } \ No newline at end of file diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 5ae242a3..e4716d78 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -482,14 +482,29 @@ ] }, "ElicitationCapability": { - "description": "Capability for handling elicitation requests from servers.\n\nElicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", + "description": "Elicitation allows servers to request interactive input from users during tool execution.\nThis capability indicates that a client can handle elicitation requests and present\nappropriate UI to users for collecting the requested information.", "type": "object", "properties": { - "schemaValidation": { - "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", - "type": [ - "boolean", - "null" + "form": { + "description": "Whether client supports form-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/FormElicitationCapability" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "Whether client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/definitions/UrlElicitationCapability" + }, + { + "type": "null" + } ] } } @@ -529,6 +544,19 @@ "message" ] }, + "FormElicitationCapability": { + "description": "Capability for handling elicitation requests from servers.\n\nCapability for form mode elicitation.", + "type": "object", + "properties": { + "schemaValidation": { + "description": "Whether the client supports JSON Schema validation for elicitation responses.\nWhen true, the client will validate user input against the requested_schema\nbefore sending the response back to the server.", + "type": [ + "boolean", + "null" + ] + } + } + }, "GetPromptRequestMethod": { "type": "string", "format": "const", @@ -1732,6 +1760,10 @@ "required": [ "uri" ] + }, + "UrlElicitationCapability": { + "description": "Capability for URL mode elicitation.", + "type": "object" } } } \ No newline at end of file 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 1816e1cb..a2f3b3bb 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 @@ -505,25 +505,67 @@ ] }, "CreateElicitationRequestParam": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", - "type": "object", - "properties": { - "message": { - "description": "Human-readable message explaining what input is needed from the user.\nThis should be clear and provide sufficient context for the user to understand\nwhat information they need to provide.", - "type": "string" + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam::FormElicitationParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = CreateElicitationRequestParam::UrlElicitationParam {\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] }, - "requestedSchema": { - "description": "Type-safe schema defining the expected structure and validation rules for the user's response.\nThis enforces the MCP 2025-06-18 specification that elicitation schemas must be objects\nwith primitive-typed properties.", - "allOf": [ - { + { + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] + }, + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "requestedSchema": { "$ref": "#/definitions/ElicitationSchema" } + }, + "required": [ + "message", + "requestedSchema" ] } - }, - "required": [ - "message", - "requestedSchema" ] }, "CreateElicitationResult": { @@ -685,11 +727,28 @@ } ] }, + "ElicitationCompletionNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/elicitation/complete" + }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, + "ElicitationResponseNotificationParam": { + "description": "Notification parameters for an url elicitation completion notification.", + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + } + }, + "required": [ + "elicitationId" + ] + }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -1031,6 +1090,9 @@ { "$ref": "#/definitions/NotificationNoParam3" }, + { + "$ref": "#/definitions/Notification5" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1447,6 +1509,21 @@ "params" ] }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCompletionNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ElicitationResponseNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { 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 1816e1cb..a2f3b3bb 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 @@ -505,25 +505,67 @@ ] }, "CreateElicitationRequestParam": { - "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```", - "type": "object", - "properties": { - "message": { - "description": "Human-readable message explaining what input is needed from the user.\nThis should be clear and provide sufficient context for the user to understand\nwhat information they need to provide.", - "type": "string" + "description": "Parameters for creating an elicitation request to gather user input.\n\nThis structure contains everything needed to request interactive input from a user:\n- A human-readable message explaining what information is needed\n- A type-safe schema defining the expected structure of the response\n\n# Example\n1. Form-based elicitation request\n```rust\nuse rmcp::model::*;\n\nlet params = CreateElicitationRequestParam::FormElicitationParam {\n message: \"Please provide your email\".to_string(),\n requested_schema: ElicitationSchema::builder()\n .required_email(\"email\")\n .build()\n .unwrap(),\n};\n```\n2. URL-based elicitation request\n```rust\nuse rmcp::model::*;\nlet params = CreateElicitationRequestParam::UrlElicitationParam {\n message: \"Please provide your feedback at the following URL\".to_string(),\n url: \"https://example.com/feedback\".to_string(),\n elicitation_id: \"unique-id-123\".to_string(),\n};\n```", + "anyOf": [ + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "form" + }, + "requestedSchema": { + "$ref": "#/definitions/ElicitationSchema" + } + }, + "required": [ + "mode", + "message", + "requestedSchema" + ] }, - "requestedSchema": { - "description": "Type-safe schema defining the expected structure and validation rules for the user's response.\nThis enforces the MCP 2025-06-18 specification that elicitation schemas must be objects\nwith primitive-typed properties.", - "allOf": [ - { + { + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "mode", + "message", + "url", + "elicitationId" + ] + }, + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "requestedSchema": { "$ref": "#/definitions/ElicitationSchema" } + }, + "required": [ + "message", + "requestedSchema" ] } - }, - "required": [ - "message", - "requestedSchema" ] }, "CreateElicitationResult": { @@ -685,11 +727,28 @@ } ] }, + "ElicitationCompletionNotificationMethod": { + "type": "string", + "format": "const", + "const": "notifications/elicitation/complete" + }, "ElicitationCreateRequestMethod": { "type": "string", "format": "const", "const": "elicitation/create" }, + "ElicitationResponseNotificationParam": { + "description": "Notification parameters for an url elicitation completion notification.", + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + } + }, + "required": [ + "elicitationId" + ] + }, "ElicitationSchema": { "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", @@ -1031,6 +1090,9 @@ { "$ref": "#/definitions/NotificationNoParam3" }, + { + "$ref": "#/definitions/Notification5" + }, { "$ref": "#/definitions/CustomNotification" } @@ -1447,6 +1509,21 @@ "params" ] }, + "Notification5": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ElicitationCompletionNotificationMethod" + }, + "params": { + "$ref": "#/definitions/ElicitationResponseNotificationParam" + } + }, + "required": [ + "method", + "params" + ] + }, "NotificationNoParam": { "type": "object", "properties": { diff --git a/examples/servers/src/elicitation_stdio.rs b/examples/servers/src/elicitation_stdio.rs index 10ee6611..82f8d696 100644 --- a/examples/servers/src/elicitation_stdio.rs +++ b/examples/servers/src/elicitation_stdio.rs @@ -17,6 +17,7 @@ use rmcp::{ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tracing_subscriber::{self, EnvFilter}; +use url::Url; /// User information request #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -106,6 +107,48 @@ impl ElicitationServer { "User name reset. Next greeting will ask for name again.".to_string(), )])) } + + #[tool(description = "Example of URL elicitation")] + pub async fn secure_tool_call( + &self, + context: RequestContext, + ) -> std::result::Result { + let elicit_result = context + .peer + .elicit_url( + "User must visit the following URL to complete tool call", + Url::parse("https://example.com/complete_tool").expect("valid URL"), + "elicit_123", + ) + .await + .map_err(|e| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Url elicitation has failed: {}", e), + None, + ) + })?; + match elicit_result { + ElicitationAction::Accept => { + // Mock notifying completion + let _ = context + .peer + .notify_url_elicitation_completed(ElicitationResponseNotificationParam { + elicitation_id: "elicit_123".to_string(), + }) + .await; + Ok(CallToolResult::success(vec![Content::text( + "Elicitation via URL successful".to_string(), + )])) + } + ElicitationAction::Cancel => Ok(CallToolResult::success(vec![Content::text( + "Elicitation via URL cancelled by user".to_string(), + )])), + ElicitationAction::Decline => Ok(CallToolResult::error(vec![Content::text( + "Elicitation via URL declined by user".to_string(), + )])), + } + } } #[tool_handler]