Skip to content

Commit 3b18efc

Browse files
committed
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
1 parent 0be52a1 commit 3b18efc

File tree

4 files changed

+312
-71
lines changed

4 files changed

+312
-71
lines changed

crates/rmcp/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +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 = ["server", "schemars"]
82+
elicitation = []
8383

8484
# reqwest http client
8585
__reqwest = ["dep:reqwest"]
@@ -202,3 +202,8 @@ path = "tests/test_message_schema.rs"
202202
name = "test_progress_subscriber"
203203
required-features = ["server", "client", "macros"]
204204
path = "tests/test_progress_subscriber.rs"
205+
206+
[[test]]
207+
name = "test_elicitation"
208+
required-features = ["elicitation", "client", "server"]
209+
path = "tests/test_elicitation.rs"

crates/rmcp/src/model/capabilities.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ impl<const E: bool, const S: bool>
286286
}
287287
}
288288

289+
#[cfg(feature = "elicitation")]
289290
impl<const E: bool, const R: bool, const S: bool>
290291
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, R, S, true>>
291292
{

crates/rmcp/src/service/server.rs

Lines changed: 125 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ use std::borrow::Cow;
33
use thiserror::Error;
44

55
use super::*;
6+
#[cfg(feature = "elicitation")]
7+
use crate::model::{
8+
CreateElicitationRequest, CreateElicitationRequestParam, CreateElicitationResult,
9+
};
610
use crate::{
711
model::{
812
CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage,
9-
ClientNotification, ClientRequest, ClientResult, CreateElicitationRequest,
10-
CreateElicitationRequestParam, CreateElicitationResult, CreateMessageRequest,
13+
ClientNotification, ClientRequest, ClientResult, CreateMessageRequest,
1114
CreateMessageRequestParam, CreateMessageResult, ErrorData, ListRootsRequest,
1215
ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam,
1316
ProgressNotification, ProgressNotificationParam, PromptListChangedNotification,
@@ -325,12 +328,68 @@ macro_rules! method {
325328
Ok(())
326329
}
327330
};
331+
332+
// Timeout-only variants (base method should be created separately with peer_req)
333+
(peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => {
334+
pub async fn $method_with_timeout(
335+
&self,
336+
timeout: Option<std::time::Duration>,
337+
) -> Result<$Resp, ServiceError> {
338+
let request = ServerRequest::$Req($Req {
339+
method: Default::default(),
340+
extensions: Default::default(),
341+
});
342+
let options = crate::service::PeerRequestOptions {
343+
timeout,
344+
meta: None,
345+
};
346+
let result = self
347+
.send_request_with_option(request, options)
348+
.await?
349+
.await_response()
350+
.await?;
351+
match result {
352+
ClientResult::$Resp(result) => Ok(result),
353+
_ => Err(ServiceError::UnexpectedResponse),
354+
}
355+
}
356+
};
357+
358+
(peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => {
359+
pub async fn $method_with_timeout(
360+
&self,
361+
params: $Param,
362+
timeout: Option<std::time::Duration>,
363+
) -> Result<$Resp, ServiceError> {
364+
let request = ServerRequest::$Req($Req {
365+
method: Default::default(),
366+
params,
367+
extensions: Default::default(),
368+
});
369+
let options = crate::service::PeerRequestOptions {
370+
timeout,
371+
meta: None,
372+
};
373+
let result = self
374+
.send_request_with_option(request, options)
375+
.await?
376+
.await_response()
377+
.await?;
378+
match result {
379+
ClientResult::$Resp(result) => Ok(result),
380+
_ => Err(ServiceError::UnexpectedResponse),
381+
}
382+
}
383+
};
328384
}
329385

330386
impl Peer<RoleServer> {
331387
method!(peer_req create_message CreateMessageRequest(CreateMessageRequestParam) => CreateMessageResult);
332388
method!(peer_req list_roots ListRootsRequest() => ListRootsResult);
389+
#[cfg(feature = "elicitation")]
333390
method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult);
391+
#[cfg(feature = "elicitation")]
392+
method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParam) => CreateElicitationResult);
334393

335394
method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam));
336395
method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam));
@@ -347,6 +406,7 @@ impl Peer<RoleServer> {
347406
// =============================================================================
348407

349408
/// Errors that can occur during typed elicitation operations
409+
#[cfg(feature = "elicitation")]
350410
#[derive(Error, Debug)]
351411
pub enum ElicitationError {
352412
/// The elicitation request failed at the service level
@@ -373,6 +433,7 @@ pub enum ElicitationError {
373433
CapabilityNotSupported,
374434
}
375435

436+
#[cfg(feature = "elicitation")]
376437
impl Peer<RoleServer> {
377438
/// Check if the client supports elicitation capability
378439
///
@@ -387,69 +448,6 @@ impl Peer<RoleServer> {
387448
}
388449
}
389450

390-
/// Request structured data from the user using a custom JSON schema.
391-
///
392-
/// This is the most flexible elicitation method, allowing you to request
393-
/// any kind of structured input using JSON Schema validation.
394-
///
395-
/// # Arguments
396-
/// * `message` - The prompt message for the user
397-
/// * `schema` - JSON Schema defining the expected data structure
398-
///
399-
/// # Returns
400-
/// * `Ok(Some(data))` if user provided valid data
401-
/// * `Ok(None)` if user declined or cancelled
402-
///
403-
/// # Example
404-
/// ```rust,no_run
405-
/// # use rmcp::*;
406-
/// # use rmcp::service::ElicitationError;
407-
/// # use serde_json::json;
408-
/// # async fn example(peer: Peer<RoleServer>) -> Result<(), ElicitationError> {
409-
/// let schema = json!({
410-
/// "type": "object",
411-
/// "properties": {
412-
/// "name": {"type": "string"},
413-
/// "email": {"type": "string", "format": "email"},
414-
/// "age": {"type": "integer", "minimum": 0}
415-
/// },
416-
/// "required": ["name", "email"]
417-
/// });
418-
///
419-
/// let user_data = peer.elicit_structured_input(
420-
/// "Please provide your contact information:",
421-
/// schema.as_object().unwrap()
422-
/// ).await?;
423-
///
424-
/// if let Some(data) = user_data {
425-
/// println!("Received user data: {}", data);
426-
/// }
427-
/// # Ok(())
428-
/// # }
429-
/// ```
430-
pub async fn elicit_structured_input(
431-
&self,
432-
message: impl Into<String>,
433-
schema: &crate::model::JsonObject,
434-
) -> Result<Option<serde_json::Value>, ElicitationError> {
435-
// Check if client supports elicitation capability
436-
if !self.supports_elicitation() {
437-
return Err(ElicitationError::CapabilityNotSupported);
438-
}
439-
440-
let response = self
441-
.create_elicitation(CreateElicitationRequestParam {
442-
message: message.into(),
443-
requested_schema: schema.clone(),
444-
})
445-
.await?;
446-
447-
match response.action {
448-
crate::model::ElicitationAction::Accept => Ok(response.content),
449-
_ => Ok(None),
450-
}
451-
}
452-
453451
/// Request typed data from the user with automatic schema generation.
454452
///
455453
/// This method automatically generates the JSON schema from the Rust type using `schemars`,
@@ -518,8 +516,62 @@ impl Peer<RoleServer> {
518516
/// # Ok(())
519517
/// # }
520518
/// ```
521-
#[cfg(feature = "schemars")]
519+
#[cfg(all(feature = "schemars", feature = "elicitation"))]
522520
pub async fn elicit<T>(&self, message: impl Into<String>) -> Result<Option<T>, ElicitationError>
521+
where
522+
T: schemars::JsonSchema + for<'de> serde::Deserialize<'de>,
523+
{
524+
self.elicit_with_timeout(message, None).await
525+
}
526+
527+
/// Request typed data from the user with custom timeout.
528+
///
529+
/// Same as `elicit()` but allows specifying a custom timeout for the request.
530+
/// If the user doesn't respond within the timeout, the request will be cancelled.
531+
///
532+
/// # Arguments
533+
/// * `message` - The prompt message for the user
534+
/// * `timeout` - Optional timeout duration. If None, uses default timeout behavior
535+
///
536+
/// # Returns
537+
/// Same as `elicit()` but may also return `ServiceError::Timeout` if timeout expires
538+
///
539+
/// # Example
540+
/// ```rust,no_run
541+
/// # use rmcp::*;
542+
/// # use rmcp::service::ElicitationError;
543+
/// # use serde::{Deserialize, Serialize};
544+
/// # use schemars::JsonSchema;
545+
/// # use std::time::Duration;
546+
/// #
547+
/// #[derive(Debug, Serialize, Deserialize, JsonSchema)]
548+
/// struct QuickResponse {
549+
/// answer: String,
550+
/// }
551+
///
552+
/// # async fn example(peer: Peer<RoleServer>) -> Result<(), Box<dyn std::error::Error>> {
553+
/// // Give user 30 seconds to respond
554+
/// let timeout = Some(Duration::from_secs(30));
555+
/// match peer.elicit_with_timeout::<QuickResponse>(
556+
/// "Quick question - what's your answer?",
557+
/// timeout
558+
/// ).await {
559+
/// Ok(Some(response)) => println!("Got answer: {}", response.answer),
560+
/// Ok(None) => println!("User declined"),
561+
/// Err(ElicitationError::Service(ServiceError::Timeout { .. })) => {
562+
/// println!("User didn't respond in time");
563+
/// }
564+
/// Err(e) => return Err(e.into()),
565+
/// }
566+
/// # Ok(())
567+
/// # }
568+
/// ```
569+
#[cfg(all(feature = "schemars", feature = "elicitation"))]
570+
pub async fn elicit_with_timeout<T>(
571+
&self,
572+
message: impl Into<String>,
573+
timeout: Option<std::time::Duration>,
574+
) -> Result<Option<T>, ElicitationError>
523575
where
524576
T: schemars::JsonSchema + for<'de> serde::Deserialize<'de>,
525577
{
@@ -532,10 +584,13 @@ impl Peer<RoleServer> {
532584
let schema = crate::handler::server::tool::schema_for_type::<T>();
533585

534586
let response = self
535-
.create_elicitation(CreateElicitationRequestParam {
536-
message: message.into(),
537-
requested_schema: schema,
538-
})
587+
.create_elicitation_with_timeout(
588+
CreateElicitationRequestParam {
589+
message: message.into(),
590+
requested_schema: schema,
591+
},
592+
timeout,
593+
)
539594
.await?;
540595

541596
match response.action {

0 commit comments

Comments
 (0)