Skip to content

Commit ce4a03a

Browse files
committed
feat: add typed elicitation API with enhanced error handling
- Add new 'elicitation' feature that depends on 'client' and 'schemars' - Implement elicit<T>() method for type-safe elicitation with automatic schema generation - Remove convenience methods (elicit_confirmation, elicit_text_input, elicit_choice) - Add ElicitationError enum with detailed error variants: - Service: underlying service errors - UserDeclined: user cancelled or declined request - ParseError: response parsing failed with context - NoContent: no response content provided - Update documentation with comprehensive examples and error handling - Add comprehensive tests for typed elicitation and error handling
1 parent 4acf13c commit ce4a03a

File tree

3 files changed

+353
-185
lines changed

3 files changed

+353
-185
lines changed

crates/rmcp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ default = ["base64", "macros", "server"]
7979
client = ["dep:tokio-stream"]
8080
server = ["transport-async-rw", "dep:schemars"]
8181
macros = ["dep:rmcp-macros", "dep:paste"]
82+
elicitation = ["client", "schemars"]
8283

8384
# reqwest http client
8485
__reqwest = ["dep:reqwest"]

crates/rmcp/src/service/client.rs

Lines changed: 114 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ use crate::{
2222
transport::DynamicTransportError,
2323
};
2424

25+
/// Errors that can occur during typed elicitation operations
26+
#[derive(Error, Debug)]
27+
pub enum ElicitationError {
28+
/// The elicitation request failed at the service level
29+
#[error("Service error: {0}")]
30+
Service(#[from] ServiceError),
31+
32+
/// User declined to provide input or cancelled the request
33+
#[error("User declined or cancelled the request")]
34+
UserDeclined,
35+
36+
/// The response data could not be parsed into the requested type
37+
#[error("Failed to parse response data: {error}\nReceived data: {data}")]
38+
ParseError {
39+
error: serde_json::Error,
40+
data: serde_json::Value,
41+
},
42+
43+
/// No response content was provided by the user
44+
#[error("No response content provided")]
45+
NoContent,
46+
}
47+
2548
/// It represents the error that may occur when serving the client.
2649
///
2750
/// if you want to handle the error, you can use `serve_client_with_ct` or `serve_client` with `Result<RunningService<RoleClient, S>, ClientError>`
@@ -397,247 +420,154 @@ impl Peer<RoleClient> {
397420
// ELICITATION CONVENIENCE METHODS
398421
// =============================================================================
399422

400-
/// Request a simple yes/no confirmation from the user.
401-
///
402-
/// This is a convenience method for requesting boolean confirmation
403-
/// from users during tool execution.
404-
///
405-
/// # Arguments
406-
/// * `message` - The question to ask the user
407-
///
408-
/// # Returns
409-
/// * `Ok(Some(true))` if user accepted and confirmed
410-
/// * `Ok(Some(false))` if user accepted but declined
411-
/// * `Ok(None)` if user declined to answer or cancelled
412-
///
413-
/// # Example
414-
/// ```rust,no_run
415-
/// # use rmcp::*;
416-
/// # async fn example(peer: Peer<RoleClient>) -> Result<(), ServiceError> {
417-
/// let confirmed = peer.elicit_confirmation("Delete this file?").await?;
418-
/// match confirmed {
419-
/// Some(true) => println!("User confirmed deletion"),
420-
/// Some(false) => println!("User declined deletion"),
421-
/// None => println!("User cancelled or declined to answer"),
422-
/// }
423-
/// # Ok(())
424-
/// # }
425-
/// ```
426-
pub async fn elicit_confirmation(
427-
&self,
428-
message: impl Into<String>,
429-
) -> Result<Option<bool>, ServiceError> {
430-
use serde_json::json;
431-
432-
let response = self
433-
.create_elicitation(CreateElicitationRequestParam {
434-
message: message.into(),
435-
requested_schema: json!({
436-
"type": "boolean",
437-
"description": "User confirmation (true for yes, false for no)"
438-
})
439-
.as_object()
440-
.unwrap()
441-
.clone(),
442-
})
443-
.await?;
444-
445-
match response.action {
446-
crate::model::ElicitationAction::Accept => {
447-
if let Some(value) = response.content {
448-
Ok(value.as_bool())
449-
} else {
450-
Ok(None)
451-
}
452-
}
453-
_ => Ok(None),
454-
}
455-
}
456-
457-
/// Request text input from the user.
423+
/// Request structured data from the user using a custom JSON schema.
458424
///
459-
/// This is a convenience method for requesting string input from users.
425+
/// This is the most flexible elicitation method, allowing you to request
426+
/// any kind of structured input using JSON Schema validation.
460427
///
461428
/// # Arguments
462429
/// * `message` - The prompt message for the user
463-
/// * `required` - Whether the input is required (cannot be empty)
430+
/// * `schema` - JSON Schema defining the expected data structure
464431
///
465432
/// # Returns
466-
/// * `Ok(Some(text))` if user provided input
433+
/// * `Ok(Some(data))` if user provided valid data
467434
/// * `Ok(None)` if user declined or cancelled
468435
///
469436
/// # Example
470437
/// ```rust,no_run
471438
/// # use rmcp::*;
439+
/// # use serde_json::json;
472440
/// # async fn example(peer: Peer<RoleClient>) -> Result<(), ServiceError> {
473-
/// let name = peer.elicit_text_input("Please enter your name:", false).await?;
474-
/// if let Some(name) = name {
475-
/// println!("Hello, {}!", name);
441+
/// let schema = json!({
442+
/// "type": "object",
443+
/// "properties": {
444+
/// "name": {"type": "string"},
445+
/// "email": {"type": "string", "format": "email"},
446+
/// "age": {"type": "integer", "minimum": 0}
447+
/// },
448+
/// "required": ["name", "email"]
449+
/// });
450+
///
451+
/// let user_data = peer.elicit_structured_input(
452+
/// "Please provide your contact information:",
453+
/// schema.as_object().unwrap()
454+
/// ).await?;
455+
///
456+
/// if let Some(data) = user_data {
457+
/// println!("Received user data: {}", data);
476458
/// }
477459
/// # Ok(())
478460
/// # }
479461
/// ```
480-
pub async fn elicit_text_input(
462+
pub async fn elicit_structured_input(
481463
&self,
482464
message: impl Into<String>,
483-
required: bool,
484-
) -> Result<Option<String>, ServiceError> {
485-
use serde_json::json;
486-
487-
let mut schema = json!({
488-
"type": "string",
489-
"description": "User text input"
490-
});
491-
492-
if required {
493-
schema["minLength"] = json!(1);
494-
}
495-
465+
schema: &crate::model::JsonObject,
466+
) -> Result<Option<serde_json::Value>, ServiceError> {
496467
let response = self
497468
.create_elicitation(CreateElicitationRequestParam {
498469
message: message.into(),
499-
requested_schema: schema.as_object().unwrap().clone(),
470+
requested_schema: schema.clone(),
500471
})
501472
.await?;
502473

503474
match response.action {
504-
crate::model::ElicitationAction::Accept => {
505-
if let Some(value) = response.content {
506-
Ok(value.as_str().map(|s| s.to_string()))
507-
} else {
508-
Ok(None)
509-
}
510-
}
475+
crate::model::ElicitationAction::Accept => Ok(response.content),
511476
_ => Ok(None),
512477
}
513478
}
514479

515-
/// Request the user to choose from multiple options.
480+
/// Request typed data from the user with automatic schema generation.
481+
///
482+
/// This method automatically generates the JSON schema from the Rust type using `schemars`,
483+
/// eliminating the need to manually create schemas. The response is automatically parsed
484+
/// into the requested type.
485+
///
486+
/// **Requires the `elicitation` feature to be enabled.**
516487
///
517-
/// This is a convenience method for presenting users with a list of choices.
488+
/// # Type Requirements
489+
/// The type `T` must implement:
490+
/// - `schemars::JsonSchema` - for automatic schema generation
491+
/// - `serde::Deserialize` - for parsing the response
518492
///
519493
/// # Arguments
520494
/// * `message` - The prompt message for the user
521-
/// * `options` - The available options to choose from
522495
///
523496
/// # Returns
524-
/// * `Ok(Some(index))` if user selected an option (0-based index)
525-
/// * `Ok(None)` if user declined or cancelled
497+
/// * `Ok(Some(data))` if user provided valid data that matches type T
498+
/// * `Err(ElicitationError::UserDeclined)` if user declined or cancelled the request
499+
/// * `Err(ElicitationError::ParseError { .. })` if response data couldn't be parsed into type T
500+
/// * `Err(ElicitationError::NoContent)` if no response content was provided
501+
/// * `Err(ElicitationError::Service(_))` if the underlying service call failed
526502
///
527503
/// # Example
504+
///
505+
/// Add to your `Cargo.toml`:
506+
/// ```toml
507+
/// [dependencies]
508+
/// rmcp = { version = "0.3", features = ["elicitation"] }
509+
/// serde = { version = "1.0", features = ["derive"] }
510+
/// schemars = "1.0"
511+
/// ```
512+
///
528513
/// ```rust,no_run
529514
/// # use rmcp::*;
530-
/// # async fn example(peer: Peer<RoleClient>) -> Result<(), ServiceError> {
531-
/// let options = vec!["Save", "Discard", "Cancel"];
532-
/// let choice = peer.elicit_choice("What would you like to do?", &options).await?;
533-
/// match choice {
534-
/// Some(0) => println!("User chose to save"),
535-
/// Some(1) => println!("User chose to discard"),
536-
/// Some(2) => println!("User chose to cancel"),
537-
/// _ => println!("User made no choice"),
515+
/// # use serde::{Deserialize, Serialize};
516+
/// # use schemars::JsonSchema;
517+
/// #
518+
/// #[derive(Debug, Serialize, Deserialize, JsonSchema)]
519+
/// struct UserProfile {
520+
/// #[schemars(description = "Full name")]
521+
/// name: String,
522+
/// #[schemars(description = "Email address")]
523+
/// email: String,
524+
/// #[schemars(description = "Age")]
525+
/// age: u8,
526+
/// }
527+
///
528+
/// # async fn example(peer: Peer<RoleClient>) -> Result<(), Box<dyn std::error::Error>> {
529+
/// match peer.elicit::<UserProfile>("Please enter your profile information").await {
530+
/// Ok(Some(profile)) => {
531+
/// println!("Name: {}, Email: {}, Age: {}", profile.name, profile.email, profile.age);
532+
/// }
533+
/// Err(ElicitationError::UserDeclined) => {
534+
/// println!("User declined to provide information");
535+
/// }
536+
/// Err(ElicitationError::ParseError { error, data }) => {
537+
/// println!("Failed to parse response: {}\nData: {}", error, data);
538+
/// }
539+
/// Err(e) => return Err(e.into()),
538540
/// }
539541
/// # Ok(())
540542
/// # }
541543
/// ```
542-
pub async fn elicit_choice(
543-
&self,
544-
message: impl Into<String>,
545-
options: &[impl AsRef<str>],
546-
) -> Result<Option<usize>, ServiceError> {
547-
use serde_json::json;
548-
549-
let option_strings: Vec<String> = options.iter().map(|s| s.as_ref().to_string()).collect();
544+
#[cfg(feature = "schemars")]
545+
pub async fn elicit<T>(&self, message: impl Into<String>) -> Result<Option<T>, ElicitationError>
546+
where
547+
T: schemars::JsonSchema + for<'de> serde::Deserialize<'de>,
548+
{
549+
// Generate schema automatically from type
550+
let schema = crate::handler::server::tool::schema_for_type::<T>();
550551

551552
let response = self
552553
.create_elicitation(CreateElicitationRequestParam {
553554
message: message.into(),
554-
requested_schema: json!({
555-
"type": "integer",
556-
"minimum": 0,
557-
"maximum": option_strings.len() - 1,
558-
"description": format!("Choose an option: {}", option_strings.join(", "))
559-
})
560-
.as_object()
561-
.unwrap()
562-
.clone(),
555+
requested_schema: schema,
563556
})
564557
.await?;
565558

566559
match response.action {
567560
crate::model::ElicitationAction::Accept => {
568561
if let Some(value) = response.content {
569-
if let Some(index) = value.as_u64() {
570-
let index = index as usize;
571-
if index < options.len() {
572-
Ok(Some(index))
573-
} else {
574-
Ok(None) // Invalid index
575-
}
576-
} else {
577-
Ok(None)
562+
match serde_json::from_value::<T>(value.clone()) {
563+
Ok(parsed) => Ok(Some(parsed)),
564+
Err(error) => Err(ElicitationError::ParseError { error, data: value }),
578565
}
579566
} else {
580-
Ok(None)
567+
Err(ElicitationError::NoContent)
581568
}
582569
}
583-
_ => Ok(None),
584-
}
585-
}
586-
587-
/// Request structured data from the user using a custom JSON schema.
588-
///
589-
/// This is the most flexible elicitation method, allowing you to request
590-
/// any kind of structured input using JSON Schema validation.
591-
///
592-
/// # Arguments
593-
/// * `message` - The prompt message for the user
594-
/// * `schema` - JSON Schema defining the expected data structure
595-
///
596-
/// # Returns
597-
/// * `Ok(Some(data))` if user provided valid data
598-
/// * `Ok(None)` if user declined or cancelled
599-
///
600-
/// # Example
601-
/// ```rust,no_run
602-
/// # use rmcp::*;
603-
/// # use serde_json::json;
604-
/// # async fn example(peer: Peer<RoleClient>) -> Result<(), ServiceError> {
605-
/// let schema = json!({
606-
/// "type": "object",
607-
/// "properties": {
608-
/// "name": {"type": "string"},
609-
/// "email": {"type": "string", "format": "email"},
610-
/// "age": {"type": "integer", "minimum": 0}
611-
/// },
612-
/// "required": ["name", "email"]
613-
/// });
614-
///
615-
/// let user_data = peer.elicit_structured_input(
616-
/// "Please provide your contact information:",
617-
/// schema.as_object().unwrap()
618-
/// ).await?;
619-
///
620-
/// if let Some(data) = user_data {
621-
/// println!("Received user data: {}", data);
622-
/// }
623-
/// # Ok(())
624-
/// # }
625-
/// ```
626-
pub async fn elicit_structured_input(
627-
&self,
628-
message: impl Into<String>,
629-
schema: &crate::model::JsonObject,
630-
) -> Result<Option<serde_json::Value>, ServiceError> {
631-
let response = self
632-
.create_elicitation(CreateElicitationRequestParam {
633-
message: message.into(),
634-
requested_schema: schema.clone(),
635-
})
636-
.await?;
637-
638-
match response.action {
639-
crate::model::ElicitationAction::Accept => Ok(response.content),
640-
_ => Ok(None),
570+
_ => Err(ElicitationError::UserDeclined),
641571
}
642572
}
643573
}

0 commit comments

Comments
 (0)