feat: Add MCP Elicitation support (#332)

* feat: implement MCP elicitation support for interactive user input

Adds comprehensive elicitation functionality according to MCP 2025-06-18 specification:

Core Features:
- ElicitationAction enum (Accept, Decline, Cancel)
- CreateElicitationRequestParam and CreateElicitationResult structures
- Protocol version V_2025_06_18 with elicitation methods
- Full JSON-RPC integration with method constants

Capabilities Integration:
- ElicitationCapability with schema validation support
- ClientCapabilities builder pattern integration
- enable_elicitation() and enable_elicitation_schema_validation() methods

Handler Support:
- create_elicitation method in ClientHandler and ServerHandler traits
- Integration with existing request/response union types
- Async/await compatible implementation

Service Layer:
- Basic create_elicitation method via macro expansion
- Four convenience methods for common scenarios:
  * elicit_confirmation() - yes/no questions
  * elicit_text_input() - string input with optional requirements
  * elicit_choice() - selection from multiple options
  * elicit_structured_input() - complex data via JSON Schema

Comprehensive Testing:
- 11 test cases covering all functionality aspects
- JSON serialization/deserialization validation
- MCP specification compliance verification
- Error handling and edge cases
- Performance benchmarks
- Capabilities integration tests

All tests pass and code follows project standards.

* 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

* fix: correct elicitation direction to comply with MCP 2025-06-18

- Remove CreateElicitationRequest from ClientRequest - clients cannot initiate elicitation
- Move elicit methods from client to server - servers now request user input
- Add comprehensive direction tests verifying Server→Client→Server flow
- Maintain CreateElicitationResult in ClientResult for proper responses
- Update handlers to reflect correct message routing
- Add elicitation feature flag for typed schema generation

Fixes elicitation direction to match specification where servers request
interactive user input from clients, not the reverse.

* feat: add elicitation capability checking for server methods

- Add supports_elicitation() method to check client capabilities
- Add CapabilityNotSupported error variant to ElicitationError
- Update elicit_structured_input() to check capabilities before execution
- Update elicit<T>() method to check capabilities before execution
- Add comprehensive tests for capability checking functionality
- Tests verify that servers check client capabilities before sending elicitation requests
- Ensures compliance with MCP 2025-06-18 specification requirement

* fix: json rpc message schema

* fix: doc tests

* fix: cargo nightly fmt checks

* fix: clippy

* refactor: separate elicitation methods into dedicated impl block for RoleServer

- Move (supports_elicitation, elicit_structured_input, elicit) to separate impl block
- Move ElicitationError definition to elicitation methods section
- Keep base methods (create_message, list_roots, notify_*) in main impl block with macro
- Add section comments to distinguish general and elicitation-specific methods

* revert: rollback LATEST protocol version to V_2025_03_26

* fix: remove protocol version assertions
- Remove assertions for V_2025_06_18 protocol version

* fix: fmt checks

* feat: add timeout support for elicitation methods

- Add peer_req_with_timeout macro variants for timeout-enabled methods
- Implement create_elicitation_with_timeout() method
- Implement elicit_with_timeout() for typed elicitation with timeout
- Refactor elicit() to use elicit_with_timeout() internally
- Add 8 comprehensive timeout tests covering validation, error handling, and realistic scenarios
- Fix elicitation feature dependencies in Cargo.toml
- Add proper feature gates for elicitation-specific code

* feat: add timeout validation to prevent DoS attacks

- Add InvalidTimeout error variant for comprehensive validation
- Implement validate_timeout function with security limits (1ms-300s)
- Integrate validation into peer_req_with_timeout macros
- Add comprehensive security tests for timeout validation
- Prevent DoS attacks through unreasonable timeout values

* feat: separate UserDeclined and UserCancelled elicitation errors

According to MCP specification and PR feedback, decline and cancel
actions should be handled differently:

- UserDeclined: explicit user rejection (clicked "Decline", "No", etc.)
- UserCancelled: dismissal without explicit choice (closed dialog, Escape, etc.)

Changes:
- Split ElicitationError::UserDeclined into two distinct error types
- Update error handling logic to map each ElicitationAction correctly
- Improve documentation with proper action semantics
- Add comprehensive tests for new error types and action mapping
- Update examples to demonstrate proper error handling

This provides better error granularity allowing servers to handle
explicit declines vs cancellations appropriately as per MCP spec.

* feat: add compile-time type safety for elicitation methods

Add ElicitationSafe trait and elicit_safe\! macro to ensure elicit<T>()
methods are only used with types that generate appropriate JSON object
schemas, addressing type safety concerns from PR feedback.

Features:
- ElicitationSafe marker trait for compile-time constraints
- elicit_safe\! macro for opt-in type safety declaration
- Updated elicit<T> and elicit_with_timeout<T> to require ElicitationSafe bound
- Comprehensive documentation with examples and rationale
- Full test coverage for new type safety features

This prevents common mistakes like:
- elicit::<String>() - primitives not suitable for object schemas
- elicit::<Vec<i32>>() - arrays don't match client expectations

Breaking change: Existing code must add elicit_safe\!(TypeName) declarations
for types used with elicit methods. This is an intentional safety improvement.

* Revert "feat: add timeout validation to prevent DoS attacks"

This reverts commit 829624212b4fb55ec32566329af7ec195c987ef5.

* fix: correct doctest example in elicit_safe macro documentation

- Remove invalid async/await usage in doctest example
- Comment out the actual usage line to show intent without compilation errors
- Maintain clear documentation of the macro's purpose and usage

* refactor: remove redundant elicitation direction tests

- Remove test_elicitation_not_in_client_request (duplicated functionality)
- Remove redundant ServerRequest match in test_elicitation_direction_server_to_client
- Direction compliance is already verified by the remaining comprehensive test
- Reduces test fragility and maintenance burden

* feat: add elicitation example with user name collection

- Add elicitation server example demonstrating real MCP usage
- Implement greet_user tool with context.peer.elicit::<T>() API
- Show type-safe elicitation with elicit_safe! macro
- Include reset_name tool and MCP Inspector instructions
- Update examples documentation and dependencies

* fix: add Default impl to ElicitationServer for clippy

Resolves clippy::new_without_default warning by implementing
Default trait for ElicitationServer struct.
This commit is contained in:
Andrei G 2025-08-19 05:48:26 +02:00 • committed by GitHub
commit 81ff3159f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2287 additions and 4 deletions

View file

@ -79,6 +79,7 @@ default = ["base64", "macros", "server"]
client = ["dep:tokio-stream"]
server = ["transport-async-rw", "dep:schemars"]
macros = ["dep:rmcp-macros", "dep:paste"]
elicitation = []
# reqwest http client
__reqwest = ["dep:reqwest"]
@ -201,3 +202,8 @@ path = "tests/test_message_schema.rs"
name = "test_progress_subscriber"
required-features = ["server", "client", "macros"]
path = "tests/test_progress_subscriber.rs"
[[test]]
name = "test_elicitation"
required-features = ["elicitation", "client", "server"]
path = "tests/test_elicitation.rs"

View file

@ -21,6 +21,10 @@ impl<H: ClientHandler> Service<RoleClient> for H {
.list_roots(context)
.await
.map(ClientResult::ListRootsResult),
ServerRequest::CreateElicitationRequest(request) => self
.create_elicitation(request.params, context)
.await
.map(ClientResult::CreateElicitationResult),
}
}
@ -86,6 +90,35 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
std::future::ready(Ok(ListRootsResult::default()))
}
/// Handle an elicitation request from a server asking for user input.
///
/// This method is called when a server needs interactive input from the user
/// during tool execution. Implementations should present the message to the user,
/// collect their input according to the requested schema, and return the result.
///
/// # Arguments
/// * `request` - The elicitation request with message and schema
/// * `context` - The request context
///
/// # Returns
/// The user's response including action (accept/decline/cancel) and optional data
///
/// # Default Behavior
/// The default implementation automatically declines all elicitation requests.
/// Real clients should override this to provide user interaction.
fn create_elicitation(
&self,
request: CreateElicitationRequestParam,
context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<CreateElicitationResult, McpError>> + Send + '_ {
// Default implementation declines all requests - real clients should override this
let _ = (request, context);
std::future::ready(Ok(CreateElicitationResult {
action: ElicitationAction::Decline,
content: None,
}))
}
fn on_cancelled(
&self,
params: CancelledNotificationParam,

View file

@ -143,6 +143,7 @@ impl std::fmt::Display for ProtocolVersion {
}
impl ProtocolVersion {
pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18"));
pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26"));
pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05"));
pub const LATEST: Self = Self::V_2025_03_26;
@ -167,6 +168,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion {
match s.as_str() {
"2024-11-05" => return Ok(ProtocolVersion::V_2024_11_05),
"2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26),
"2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18),
_ => {}
}
Ok(ProtocolVersion(Cow::Owned(s)))
@ -1173,6 +1175,75 @@ pub struct ListRootsResult {
const_string!(RootsListChangedNotificationMethod = "notifications/roots/list_changed");
pub type RootsListChangedNotification = NotificationNoParam<RootsListChangedNotificationMethod>;
// =============================================================================
// ELICITATION (INTERACTIVE USER INPUT)
// =============================================================================
// Method constants for elicitation operations.
// Elicitation allows servers to request interactive input from users during tool execution.
const_string!(ElicitationCreateRequestMethod = "elicitation/create");
const_string!(ElicitationResponseNotificationMethod = "notifications/elicitation/response");
/// Represents the possible actions a user can take in response to an elicitation request.
///
/// When a server requests user input through elicitation, the user can:
/// - Accept: Provide the requested information and continue
/// - Decline: Refuse to provide the information but continue the operation
/// - Cancel: Stop the entire operation
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum ElicitationAction {
/// User accepts the request and provides the requested information
Accept,
/// User declines to provide the information but allows the operation to continue
Decline,
/// User cancels the entire operation
Cancel,
}
/// Parameters for creating an elicitation request to gather user input.
///
/// This structure contains everything needed to request interactive input from a user:
/// - A human-readable message explaining what information is needed
/// - A JSON schema defining the expected structure of the response
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[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,
/// JSON Schema defining the expected structure and validation rules for the user's response.
/// This allows clients to validate input and provide appropriate UI controls.
/// Must be a valid JSON Schema Draft 2020-12 object.
pub requested_schema: JsonObject,
}
/// The result returned by a client in response to an elicitation request.
///
/// Contains the user's decision (accept/decline/cancel) and optionally their input data
/// if they chose to accept the request.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CreateElicitationResult {
/// The user's decision on how to handle the elicitation request
pub action: ElicitationAction,
/// The actual data provided by the user, if they accepted the request.
/// Must conform to the JSON schema specified in the original request.
/// Only present when action is Accept.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Value>,
}
/// Request type for creating an elicitation to gather user input
pub type CreateElicitationRequest =
Request<ElicitationCreateRequestMethod, CreateElicitationRequestParam>;
// =============================================================================
// TOOL EXECUTION RESULTS
// =============================================================================
@ -1430,7 +1501,7 @@ ts_union!(
);
ts_union!(
export type ClientResult = CreateMessageResult | ListRootsResult | EmptyResult;
export type ClientResult = CreateMessageResult | ListRootsResult | CreateElicitationResult | EmptyResult;
);
impl ClientResult {
@ -1445,7 +1516,8 @@ ts_union!(
export type ServerRequest =
| PingRequest
| CreateMessageRequest
| ListRootsRequest;
| ListRootsRequest
| CreateElicitationRequest;
);
ts_union!(
@ -1470,6 +1542,7 @@ ts_union!(
| ReadResourceResult
| CallToolResult
| ListToolsResult
| CreateElicitationResult
| EmptyResult
;
);

View file

@ -40,6 +40,22 @@ pub struct RootsCapabilities {
pub list_changed: Option<bool>,
}
/// 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.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ElicitationCapability {
/// 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.
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_validation: Option<bool>,
}
///
/// # Builder
/// ```rust
@ -59,6 +75,9 @@ pub struct ClientCapabilities {
pub roots: Option<RootsCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sampling: Option<JsonObject>,
/// Capability to handle elicitation requests from servers for interactive user input
#[serde(skip_serializing_if = "Option::is_none")]
pub elicitation: Option<ElicitationCapability>,
}
///
@ -253,6 +272,7 @@ builder! {
experimental: ExperimentalCapabilities,
roots: RootsCapabilities,
sampling: JsonObject,
elicitation: ElicitationCapability,
}
}
@ -267,6 +287,21 @@ impl<const E: bool, const S: bool>
}
}
#[cfg(feature = "elicitation")]
impl<const E: bool, const R: bool, const S: bool>
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, R, S, true>>
{
/// Enable JSON Schema validation for elicitation responses.
/// 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);
}
self
}
}
#[cfg(test)]
mod test {
use super::*;

View file

@ -74,6 +74,7 @@ variant_extension! {
PingRequest
CreateMessageRequest
ListRootsRequest
CreateElicitationRequest
}
}

View file

@ -3,6 +3,10 @@ use std::borrow::Cow;
use thiserror::Error;
use super::*;
#[cfg(feature = "elicitation")]
use crate::model::{
CreateElicitationRequest, CreateElicitationRequestParam, CreateElicitationResult,
};
use crate::{
model::{
CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage,
@ -324,11 +328,68 @@ macro_rules! method {
Ok(())
}
};
// Timeout-only variants (base method should be created separately with peer_req)
(peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => {
pub async fn $method_with_timeout(
&self,
timeout: Option<std::time::Duration>,
) -> Result<$Resp, ServiceError> {
let request = ServerRequest::$Req($Req {
method: Default::default(),
extensions: Default::default(),
});
let options = crate::service::PeerRequestOptions {
timeout,
meta: None,
};
let result = self
.send_request_with_option(request, options)
.await?
.await_response()
.await?;
match result {
ClientResult::$Resp(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
};
(peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => {
pub async fn $method_with_timeout(
&self,
params: $Param,
timeout: Option<std::time::Duration>,
) -> Result<$Resp, ServiceError> {
let request = ServerRequest::$Req($Req {
method: Default::default(),
params,
extensions: Default::default(),
});
let options = crate::service::PeerRequestOptions {
timeout,
meta: None,
};
let result = self
.send_request_with_option(request, options)
.await?
.await_response()
.await?;
match result {
ClientResult::$Resp(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
};
}
impl Peer<RoleServer> {
method!(peer_req create_message CreateMessageRequest(CreateMessageRequestParam) => CreateMessageResult);
method!(peer_req list_roots ListRootsRequest() => ListRootsResult);
#[cfg(feature = "elicitation")]
method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult);
#[cfg(feature = "elicitation")]
method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult);
method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam));
method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam));
@ -338,3 +399,294 @@ impl Peer<RoleServer> {
method!(peer_not notify_tool_list_changed ToolListChangedNotification);
method!(peer_not notify_prompt_list_changed PromptListChangedNotification);
}
// =============================================================================
// ELICITATION CONVENIENCE METHODS
// These methods are specific to server role and provide typed elicitation functionality
// =============================================================================
/// Errors that can occur during typed elicitation operations
#[cfg(feature = "elicitation")]
#[derive(Error, Debug)]
pub enum ElicitationError {
/// The elicitation request failed at the service level
#[error("Service error: {0}")]
Service(#[from] ServiceError),
/// User explicitly declined to provide the requested information
/// This indicates a conscious decision by the user to reject the request
/// (e.g., clicked "Reject", "Decline", "No", etc.)
#[error("User explicitly declined the request")]
UserDeclined,
/// User dismissed the request without making an explicit choice
/// This indicates the user cancelled without explicitly declining
/// (e.g., closed dialog, clicked outside, pressed Escape, etc.)
#[error("User cancelled/dismissed the request")]
UserCancelled,
/// The response data could not be parsed into the requested type
#[error("Failed to parse response data: {error}\nReceived data: {data}")]
ParseError {
error: serde_json::Error,
data: serde_json::Value,
},
/// No response content was provided by the user
#[error("No response content provided")]
NoContent,
/// Client does not support elicitation capability
#[error("Client does not support elicitation - capability not declared during initialization")]
CapabilityNotSupported,
}
/// Marker trait to ensure that elicitation types generate object-type JSON schemas.
///
/// This trait provides compile-time safety to ensure that types used with
/// `elicit<T>()` methods will generate JSON schemas of type "object", which
/// aligns with MCP client expectations for structured data input.
///
/// # Type Safety Rationale
///
/// MCP clients typically expect JSON objects for elicitation schemas to
/// provide structured forms and validation. This trait prevents common
/// mistakes like:
///
/// ```compile_fail
/// // These would not compile due to missing ElicitationSafe bound:
/// let name: String = server.elicit("Enter name").await?; // Primitive
/// let items: Vec<i32> = server.elicit("Enter items").await?; // Array
/// ```
#[cfg(feature = "elicitation")]
pub trait ElicitationSafe: schemars::JsonSchema {}
/// Macro to mark types as safe for elicitation by verifying they generate object schemas.
///
/// This macro automatically implements the `ElicitationSafe` trait for struct types
/// that should be used with `elicit<T>()` methods.
///
/// # Example
///
/// ```rust
/// use rmcp::elicit_safe;
/// use schemars::JsonSchema;
/// use serde::{Deserialize, Serialize};
///
/// #[derive(Serialize, Deserialize, JsonSchema)]
/// struct UserProfile {
/// name: String,
/// email: String,
/// }
///
/// elicit_safe!(UserProfile);
///
/// // Now safe to use in async context:
/// // let profile: UserProfile = server.elicit("Enter profile").await?;
/// ```
#[cfg(feature = "elicitation")]
#[macro_export]
macro_rules! elicit_safe {
($($t:ty),* $(,)?) => {
$(
impl $crate::service::ElicitationSafe for $t {}
)*
};
}
#[cfg(feature = "elicitation")]
impl Peer<RoleServer> {
/// Check if the client supports elicitation capability
///
/// 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 {
if let Some(client_info) = self.peer_info() {
client_info.capabilities.elicitation.is_some()
} else {
false
}
}
/// Request typed data from the user with automatic schema generation.
///
/// This method automatically generates the JSON schema from the Rust type using `schemars`,
/// eliminating the need to manually create schemas. The response is automatically parsed
/// into the requested type.
///
/// **Requires the `elicitation` feature to be enabled.**
///
/// # Type Requirements
/// The type `T` must implement:
/// - `schemars::JsonSchema` - for automatic schema generation
/// - `serde::Deserialize` - for parsing the response
///
/// # Arguments
/// * `message` - The prompt message for the user
///
/// # Returns
/// * `Ok(Some(data))` if user provided valid data that matches type T
/// * `Err(ElicitationError::UserDeclined)` if user explicitly declined the request
/// * `Err(ElicitationError::UserCancelled)` if user cancelled/dismissed the request
/// * `Err(ElicitationError::ParseError { .. })` if response data couldn't be parsed into type T
/// * `Err(ElicitationError::NoContent)` if no response content was provided
/// * `Err(ElicitationError::Service(_))` if the underlying service call failed
///
/// # Example
///
/// Add to your `Cargo.toml`:
/// ```toml
/// [dependencies]
/// rmcp = { version = "0.3", features = ["elicitation"] }
/// serde = { version = "1.0", features = ["derive"] }
/// schemars = "1.0"
/// ```
///
/// ```rust,no_run
/// # use rmcp::*;
/// # use rmcp::service::ElicitationError;
/// # use serde::{Deserialize, Serialize};
/// # use schemars::JsonSchema;
/// #
/// #[derive(Debug, Serialize, Deserialize, JsonSchema)]
/// struct UserProfile {
/// #[schemars(description = "Full name")]
/// name: String,
/// #[schemars(description = "Email address")]
/// email: String,
/// #[schemars(description = "Age")]
/// age: u8,
/// }
///
/// // Mark as safe for elicitation (generates object schema)
/// rmcp::elicit_safe!(UserProfile);
///
/// # async fn example(peer: Peer<RoleServer>) -> Result<(), Box<dyn std::error::Error>> {
/// match peer.elicit::<UserProfile>("Please enter your profile information").await {
/// Ok(Some(profile)) => {
/// println!("Name: {}, Email: {}, Age: {}", profile.name, profile.email, profile.age);
/// }
/// Ok(None) => {
/// println!("User provided no content");
/// }
/// Err(ElicitationError::UserDeclined) => {
/// println!("User explicitly declined to provide information");
/// // Handle explicit decline - perhaps offer alternatives
/// }
/// Err(ElicitationError::UserCancelled) => {
/// println!("User cancelled the request");
/// // Handle cancellation - perhaps prompt again later
/// }
/// Err(ElicitationError::ParseError { error, data }) => {
/// println!("Failed to parse response: {}\nData: {}", error, data);
/// }
/// Err(e) => return Err(e.into()),
/// }
/// # Ok(())
/// # }
/// ```
#[cfg(all(feature = "schemars", feature = "elicitation"))]
pub async fn elicit<T>(&self, message: impl Into<String>) -> Result<Option<T>, ElicitationError>
where
T: ElicitationSafe + for<'de> serde::Deserialize<'de>,
{
self.elicit_with_timeout(message, None).await
}
/// Request typed data from the user with custom timeout.
///
/// Same as `elicit()` but allows specifying a custom timeout for the request.
/// If the user doesn't respond within the timeout, the request will be cancelled.
///
/// # Arguments
/// * `message` - The prompt message for the user
/// * `timeout` - Optional timeout duration. If None, uses default timeout behavior
///
/// # Returns
/// Same as `elicit()` but may also return `ServiceError::Timeout` if timeout expires
///
/// # Example
/// ```rust,no_run
/// # use rmcp::*;
/// # use rmcp::service::ElicitationError;
/// # use serde::{Deserialize, Serialize};
/// # use schemars::JsonSchema;
/// # use std::time::Duration;
/// #
/// #[derive(Debug, Serialize, Deserialize, JsonSchema)]
/// struct QuickResponse {
/// answer: String,
/// }
///
/// // Mark as safe for elicitation
/// rmcp::elicit_safe!(QuickResponse);
///
/// # async fn example(peer: Peer<RoleServer>) -> Result<(), Box<dyn std::error::Error>> {
/// // Give user 30 seconds to respond
/// let timeout = Some(Duration::from_secs(30));
/// match peer.elicit_with_timeout::<QuickResponse>(
/// "Quick question - what's your answer?",
/// timeout
/// ).await {
/// Ok(Some(response)) => println!("Got answer: {}", response.answer),
/// Ok(None) => println!("User provided no content"),
/// Err(ElicitationError::UserDeclined) => {
/// println!("User explicitly declined");
/// // Handle explicit decline
/// }
/// Err(ElicitationError::UserCancelled) => {
/// println!("User cancelled/dismissed");
/// // Handle cancellation
/// }
/// Err(ElicitationError::Service(ServiceError::Timeout { .. })) => {
/// println!("User didn't respond in time");
/// }
/// Err(e) => return Err(e.into()),
/// }
/// # Ok(())
/// # }
/// ```
#[cfg(all(feature = "schemars", feature = "elicitation"))]
pub async fn elicit_with_timeout<T>(
&self,
message: impl Into<String>,
timeout: Option<std::time::Duration>,
) -> Result<Option<T>, ElicitationError>
where
T: ElicitationSafe + for<'de> serde::Deserialize<'de>,
{
// Check if client supports elicitation capability
if !self.supports_elicitation() {
return Err(ElicitationError::CapabilityNotSupported);
}
// Generate schema automatically from type
let schema = crate::handler::server::tool::schema_for_type::<T>();
let response = self
.create_elicitation_with_timeout(
CreateElicitationRequestParam {
message: message.into(),
requested_schema: schema,
},
timeout,
)
.await?;
match response.action {
crate::model::ElicitationAction::Accept => {
if let Some(value) = response.content {
match serde_json::from_value::<T>(value.clone()) {
Ok(parsed) => Ok(Some(parsed)),
Err(error) => Err(ElicitationError::ParseError { error, data: value }),
}
} else {
Err(ElicitationError::NoContent)
}
}
crate::model::ElicitationAction::Decline => Err(ElicitationError::UserDeclined),
crate::model::ElicitationAction::Cancel => Err(ElicitationError::UserCancelled),
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -257,6 +257,17 @@
"description": "```rust\n# use rmcp::model::ClientCapabilities;\nlet cap = ClientCapabilities::builder()\n .enable_experimental()\n .enable_roots()\n .enable_roots_list_changed()\n .build();\n```",
"type": "object",
"properties": {
"elicitation": {
"description": "Capability to handle elicitation requests from servers for interactive user input",
"anyOf": [
{
"$ref": "#/definitions/ElicitationCapability"
},
{
"type": "null"
}
]
},
"experimental": {
"type": [
"object",
@ -294,6 +305,9 @@
{
"$ref": "#/definitions/ListRootsResult"
},
{
"$ref": "#/definitions/CreateElicitationResult"
},
{
"$ref": "#/definitions/EmptyObject"
}
@ -319,6 +333,26 @@
"argument"
]
},
"CreateElicitationResult": {
"description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.",
"type": "object",
"properties": {
"action": {
"description": "The user's decision on how to handle the elicitation request",
"allOf": [
{
"$ref": "#/definitions/ElicitationAction"
}
]
},
"content": {
"description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept."
}
},
"required": [
"action"
]
},
"CreateMessageResult": {
"description": "The result of a sampling/createMessage request containing the generated response.\n\nThis structure contains the generated message along with metadata about\nhow the generation was performed and why it stopped.",
"type": "object",
@ -357,6 +391,39 @@
"content"
]
},
"ElicitationAction": {
"description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation",
"oneOf": [
{
"description": "User accepts the request and provides the requested information",
"type": "string",
"const": "accept"
},
{
"description": "User declines to provide the information but allows the operation to continue",
"type": "string",
"const": "decline"
},
{
"description": "User cancels the entire operation",
"type": "string",
"const": "cancel"
}
]
},
"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.",
"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"
]
}
}
},
"EmptyObject": {
"description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.",
"type": "object"

View file

@ -405,6 +405,45 @@
}
]
},
"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 JSON schema defining the expected structure of the response",
"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"
},
"requestedSchema": {
"description": "JSON Schema defining the expected structure and validation rules for the user's response.\nThis allows clients to validate input and provide appropriate UI controls.\nMust be a valid JSON Schema Draft 2020-12 object.",
"type": "object",
"additionalProperties": true
}
},
"required": [
"message",
"requestedSchema"
]
},
"CreateElicitationResult": {
"description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.",
"type": "object",
"properties": {
"action": {
"description": "The user's decision on how to handle the elicitation request",
"allOf": [
{
"$ref": "#/definitions/ElicitationAction"
}
]
},
"content": {
"description": "The actual data provided by the user, if they accepted the request.\nMust conform to the JSON schema specified in the original request.\nOnly present when action is Accept."
}
},
"required": [
"action"
]
},
"CreateMessageRequestMethod": {
"type": "string",
"format": "const",
@ -483,6 +522,31 @@
"maxTokens"
]
},
"ElicitationAction": {
"description": "Represents the possible actions a user can take in response to an elicitation request.\n\nWhen a server requests user input through elicitation, the user can:\n- Accept: Provide the requested information and continue\n- Decline: Refuse to provide the information but continue the operation\n- Cancel: Stop the entire operation",
"oneOf": [
{
"description": "User accepts the request and provides the requested information",
"type": "string",
"const": "accept"
},
{
"description": "User declines to provide the information but allows the operation to continue",
"type": "string",
"const": "decline"
},
{
"description": "User cancels the entire operation",
"type": "string",
"const": "cancel"
}
]
},
"ElicitationCreateRequestMethod": {
"type": "string",
"format": "const",
"const": "elicitation/create"
},
"EmptyObject": {
"description": "This is commonly used for representing empty objects in MCP messages.\n\nwithout returning any specific data.",
"type": "object"
@ -686,6 +750,9 @@
},
{
"$ref": "#/definitions/RequestNoParam2"
},
{
"$ref": "#/definitions/Request2"
}
],
"required": [
@ -1307,6 +1374,22 @@
"params"
]
},
"Request2": {
"description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)",
"type": "object",
"properties": {
"method": {
"$ref": "#/definitions/ElicitationCreateRequestMethod"
},
"params": {
"$ref": "#/definitions/CreateElicitationRequestParam"
}
},
"required": [
"method",
"params"
]
},
"RequestNoParam": {
"type": "object",
"properties": {
@ -1546,6 +1629,9 @@
{
"$ref": "#/definitions/ListToolsResult"
},
{
"$ref": "#/definitions/CreateElicitationResult"
},
{
"$ref": "#/definitions/EmptyObject"
}

View file

@ -14,6 +14,8 @@ rmcp = { workspace = true, features = [
"transport-io",
"transport-streamable-http-server",
"auth",
"elicitation",
"schemars",
] }
tokio = { version = "1", features = [
"macros",
@ -34,7 +36,7 @@ tracing-subscriber = { version = "0.3", features = [
futures = "0.3"
rand = { version = "0.9", features = ["std"] }
axum = { version = "0.8", features = ["macros"] }
schemars = { version = "1.0" }
schemars = "1.0"
reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4"
uuid = { version = "1.6", features = ["v4", "serde"] }
@ -87,3 +89,7 @@ path = "src/sampling_stdio.rs"
[[example]]
name = "servers_structured_output"
path = "src/structured_output.rs"
[[example]]
name = "servers_elicitation_stdio"
path = "src/elicitation_stdio.rs"

View file

@ -47,6 +47,7 @@ A server using streamable HTTP transport for MCP communication, with axum.
### Counter Streamable HTTP Server with Hyper (`counter_hyper_streamable_http.rs`)
A server using streamable HTTP transport for MCP communication, with hyper.
- Runs on HTTP with streaming capabilities
- Provides counter tools via HTTP streaming
- Demonstrates streamable HTTP transport configuration
@ -72,6 +73,16 @@ A simplified OAuth example showing basic token-based authentication.
- Simplified authentication flow
- Good starting point for adding authentication to MCP servers
### Elicitation Demo (`elicitation_stdio.rs`)
A working MCP server demonstrating elicitation for user name collection.
- Real MCP server using rmcp library
- `context.peer.elicit::<T>()` API usage
- Type-safe elicitation with `elicit_safe!` macro
- JSON Schema validation with schemars
- Tools: `greet_user` (collects name), `reset_name` (clears stored name)
## How to Run
Each example can be run using Cargo:
@ -97,6 +108,9 @@ cargo run --example servers_complex_auth_sse
# Run the simple OAuth SSE server
cargo run --example servers_simple_auth_sse
# Run the elicitation standard I/O server
cargo run --example servers_elicitation_stdio
```
## Testing with MCP Inspector
@ -120,6 +134,7 @@ These examples use the following main dependencies:
- `uuid`: UUID generation (used in OAuth examples)
- `chrono`: Date and time handling (used in OAuth examples)
- `rand`: Random number generation (used in OAuth examples)
- `schemars`: JSON Schema generation (used in elicitation examples)
## Common Module
@ -129,4 +144,4 @@ The `common/` directory contains shared code used across examples:
- `calculator.rs`: Calculator tool examples
- `generic_service.rs`: Generic service implementations
This modular approach allows for code reuse and demonstrates how to structure larger MCP server applications.
This modular approach allows for code reuse and demonstrates how to structure larger MCP server applications.

View file

@ -0,0 +1,143 @@
//! Simple MCP Server with Elicitation
//!
//! Demonstrates user name collection via elicitation
use std::sync::Arc;
use anyhow::Result;
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt, elicit_safe,
handler::server::{router::tool::ToolRouter, tool::Parameters},
model::*,
schemars::JsonSchema,
service::{RequestContext, RoleServer},
tool, tool_handler, tool_router,
transport::stdio,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing_subscriber::{self, EnvFilter};
/// User information request
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(description = "User information")]
pub struct UserInfo {
#[schemars(description = "User's name")]
pub name: String,
}
// Mark as safe for elicitation
elicit_safe!(UserInfo);
/// Simple tool request
#[derive(Debug, Deserialize, JsonSchema)]
pub struct GreetRequest {
pub greeting: String,
}
/// Simple server with elicitation
#[derive(Clone)]
pub struct ElicitationServer {
user_name: Arc<Mutex<Option<String>>>,
tool_router: ToolRouter<ElicitationServer>,
}
impl Default for ElicitationServer {
fn default() -> Self {
Self::new()
}
}
#[tool_router]
impl ElicitationServer {
pub fn new() -> Self {
Self {
user_name: Arc::new(Mutex::new(None)),
tool_router: Self::tool_router(),
}
}
#[tool(description = "Greet user with name collection")]
async fn greet_user(
&self,
context: RequestContext<RoleServer>,
Parameters(request): Parameters<GreetRequest>,
) -> Result<CallToolResult, McpError> {
// Check if we have user name
let current_name = self.user_name.lock().await.clone();
let user_name = if let Some(name) = current_name {
name
} else {
// Request user name via elicitation
match context
.peer
.elicit::<UserInfo>("Please provide your name".to_string())
.await
{
Ok(Some(user_info)) => {
let name = user_info.name.clone();
*self.user_name.lock().await = Some(name.clone());
name
}
Ok(None) => "Guest".to_string(), // Never happen if client checks schema
Err(_) => "Unknown".to_string(),
}
};
Ok(CallToolResult::success(vec![Content::text(format!(
"{} {}!",
request.greeting, user_name
))]))
}
#[tool(description = "Reset stored user name")]
async fn reset_name(&self) -> Result<CallToolResult, McpError> {
*self.user_name.lock().await = None;
Ok(CallToolResult::success(vec![Content::text(
"User name reset. Next greeting will ask for name again.".to_string(),
)]))
}
}
#[tool_handler]
impl ServerHandler for ElicitationServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation::from_build_env(),
instructions: Some(
"Simple server demonstrating elicitation for user name collection".to_string(),
),
..Default::default()
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
println!("Simple MCP Elicitation Demo");
// Get current executable path for Inspector
let current_exe = std::env::current_exe()
.map(|path| path.display().to_string())
.unwrap();
println!("To test with MCP Inspector:");
println!("1. Run: npx @modelcontextprotocol/inspector");
println!("2. Enter server command: {}", current_exe);
let service = ElicitationServer::new()
.serve(stdio())
.await
.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?;
service.waiting().await?;
Ok(())
}