rust-sdk/crates/rmcp/src/service/server.rs
camillelawrence 397d416fdc
feat: route SEP-2260 associated server requests to the originating SSE stream (#1029)
* feat: implement SEP-2260 require server requests to associate with client requests

* feat(service): attach originating request id to in-handler outbound requests (SEP-2260)

Reuses the ORIGINATING_REQUEST task-local from #1027; the marker rides
the request's non-serialized Extensions so the streamable HTTP server
can route associated requests to the originating SSE stream.

* feat(transport): route associated server requests to originating SSE stream (SEP-2260)

* test: end-to-end SEP-2260 stream routing over streamable HTTP

* test: register SEP-2260 routing test with required-features; drop dead list_tools

* docs: document SEP-2260 association requirement and stream routing

* docs: fix redundant explicit link on OriginatingRequestId

* docs: note marker is role-agnostic; add reason to deprecated expect

Review follow-ups: the OriginatingRequestId rustdoc now states the marker
is attached for both roles with only the server transport reading it, and
the test's deprecation suppression carries a reason per house style.

* docs: condense inline comments to repo convention

Trims multi-line inline comments added in this branch down to the terse
one-to-two-line style used elsewhere in the codebase, keeping only the
non-obvious rationale (spec references, fallback invariant, version choice).

* docs: condense SEP-2260 rustdoc sections

Tightens the duplicated per-method association section from eleven lines
to five, and trims the marker and SessionManager docs to the same
information in fewer words.

* docs: single-source SEP-2260 caller requirements on OriginatingRequestId

* feat(service): reject unassociated server-to-client requests on the client (SEP-2260)

Implements the client receive-side of SEP-2260: restricted server
requests (sampling/createMessage, roots/list, elicitation/create)
received while the client has no outbound request in flight are
answered with -32602 invalid params instead of being dispatched to the
handler. Gated on negotiated protocol >= 2026-07-28; ping is exempt.

With a request in flight we cannot tell which one the server request
belongs to (SEP-2260 defines no wire field), so this is a deliberate
under-approximation of the spec's SHOULD; exact stream-based
enforcement for streamable HTTP needs receive-side provenance plumbing
and is left as a follow-up.

---------

Co-authored-by: Alex Hancock <alexhancock@block.xyz>
2026-07-23 13:12:20 -04:00

1319 lines
48 KiB
Rust

// Sampling/Roots/Logging are SEP-2577-deprecated; internal references are expected.
#![expect(deprecated)]
#[cfg(feature = "elicitation")]
use std::collections::HashSet;
use std::{borrow::Cow, sync::Arc};
use thiserror::Error;
#[cfg(feature = "elicitation")]
use url::Url;
use super::*;
#[cfg(feature = "elicitation")]
use crate::model::{ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction};
use crate::{
model::{
CancelledNotification, CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage,
ClientNotification, ClientRequest, ClientResult, CreateMessageRequest,
CreateMessageRequestParams, CreateMessageResult, EmptyResult, ErrorData, ListRootsRequest,
ListRootsResult, LoggingMessageNotification, LoggingMessageNotificationParam,
ProgressNotification, ProgressNotificationParam, PromptListChangedNotification,
ProtocolVersion, ResourceListChangedNotification, ResourceUpdatedNotification,
ResourceUpdatedNotificationParam, ServerInfo, ServerNotification, ServerRequest,
ServerResult, SubscriptionFilter, SubscriptionsAcknowledgedNotification,
SubscriptionsAcknowledgedNotificationParams, ToolListChangedNotification,
},
transport::DynamicTransportError,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RoleServer;
impl ServiceRole for RoleServer {
type Req = ServerRequest;
type Resp = ServerResult;
type Not = ServerNotification;
type PeerReq = ClientRequest;
type PeerResp = ClientResult;
type PeerNot = ClientNotification;
type Info = ServerInfo;
type PeerInfo = ClientInfo;
type InitializeError = ServerInitializeError;
const IS_CLIENT: bool = false;
fn peer_cancelled_params(notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
match notification {
ClientNotification::CancelledNotification(notification) => Some(&notification.params),
_ => None,
}
}
fn enforce_request_association(
request: &Self::Req,
peer_info: Option<&Self::PeerInfo>,
in_request_handler_scope: bool,
) -> Result<(), ServiceError> {
let restricted = matches!(
request,
ServerRequest::CreateMessageRequest(_)
| ServerRequest::ListRootsRequest(_)
| ServerRequest::ElicitRequest(_)
);
if !restricted {
return Ok(());
}
let strict =
peer_info.is_some_and(|info| info.protocol_version >= ProtocolVersion::V_2026_07_28);
if strict && !in_request_handler_scope {
return Err(ServiceError::McpError(ErrorData::invalid_request(
"SEP-2260: server-to-client requests must be associated with an originating client request",
None,
)));
}
Ok(())
}
}
/// It represents the error that may occur when serving the server.
///
/// if you want to handle the error, you can use `serve_server_with_ct` or `serve_server` with `Result<RunningService<RoleServer, S>, ServerError>`
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ServerInitializeError {
#[error("expect initialized request, but received: {0:?}")]
ExpectedInitializeRequest(Option<ClientJsonRpcMessage>),
#[deprecated(
since = "1.4.0",
note = "The server no longer gates on the initialized notification. This variant is never constructed and will be removed in a future major release."
)]
#[error("expect initialized notification, but received: {0:?}")]
ExpectedInitializedNotification(Option<ClientJsonRpcMessage>),
#[error("connection closed: {0}")]
ConnectionClosed(String),
#[error("unexpected initialize result: {0:?}")]
UnexpectedInitializeResponse(ServerResult),
#[error("initialize failed: {0}")]
InitializeFailed(ErrorData),
#[deprecated(
since = "1.8.0",
note = "Negotiation now falls back to the server-configured version. This variant is never constructed and will be removed in a future major release."
)]
#[error("unsupported protocol version: {0}")]
UnsupportedProtocolVersion(ProtocolVersion),
#[error("Send message error {error}, when {context}")]
TransportError {
error: DynamicTransportError,
context: Cow<'static, str>,
},
#[error("Cancelled")]
Cancelled,
}
impl ServerInitializeError {
pub fn transport<T: Transport<RoleServer> + 'static>(
error: T::Error,
context: impl Into<Cow<'static, str>>,
) -> Self {
Self::TransportError {
error: DynamicTransportError::new::<T, _>(error),
context: context.into(),
}
}
}
pub type ClientSink = Peer<RoleServer>;
/// Failure to send a notification through a [`SubscriptionSink`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SubscriptionSendError {
#[error("subscription is no longer active")]
SubscriptionClosed,
#[error("notification is not allowed on a subscription stream: {0}")]
UnsupportedNotification(&'static str),
#[error("notification was not accepted for this subscription: {0}")]
NotificationNotAccepted(&'static str),
#[error(transparent)]
Service(#[from] ServiceError),
}
/// A server-side notification sink scoped to one `subscriptions/listen` request.
///
/// The sink applies the accepted filter and adds the subscription request ID to
/// every notification it sends.
#[derive(Debug, Clone)]
pub struct SubscriptionSink {
peer: Peer<RoleServer>,
id: RequestId,
accepted: Arc<SubscriptionFilter>,
active: CancellationToken,
}
impl SubscriptionSink {
fn new(
peer: Peer<RoleServer>,
id: RequestId,
accepted: Arc<SubscriptionFilter>,
active: CancellationToken,
) -> Self {
Self {
peer,
id,
accepted,
active,
}
}
/// Return the JSON-RPC ID of the originating listen request.
pub fn id(&self) -> &RequestId {
&self.id
}
/// Return the filter accepted for this subscription.
pub fn accepted(&self) -> &SubscriptionFilter {
self.accepted.as_ref()
}
/// Send an allowed change notification with subscription metadata attached.
///
/// # Errors
///
/// Returns [`SubscriptionSendError::SubscriptionClosed`] after the request
/// ends, a filter error for disallowed notifications, or a transport error.
pub async fn send(
&self,
mut notification: ServerNotification,
) -> Result<(), SubscriptionSendError> {
if self.active.is_cancelled() {
return Err(SubscriptionSendError::SubscriptionClosed);
}
match &notification {
ServerNotification::ToolListChangedNotification(_) => {
if self.accepted.tools_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/tools/list_changed",
));
}
}
ServerNotification::PromptListChangedNotification(_) => {
if self.accepted.prompts_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/prompts/list_changed",
));
}
}
ServerNotification::ResourceListChangedNotification(_) => {
if self.accepted.resources_list_changed != Some(true) {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/list_changed",
));
}
}
ServerNotification::ResourceUpdatedNotification(update) => {
let accepted = self
.accepted
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.contains(&update.params.uri));
if !accepted {
return Err(SubscriptionSendError::NotificationNotAccepted(
"notifications/resources/updated",
));
}
}
ServerNotification::SubscriptionsAcknowledgedNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/subscriptions/acknowledged",
));
}
ServerNotification::CancelledNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/cancelled",
));
}
ServerNotification::ProgressNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/progress",
));
}
ServerNotification::LoggingMessageNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/message",
));
}
// SEP-2663 task status notifications are not yet routable through
// `subscriptions/listen`: `SubscriptionFilter` has no `taskIds`
// field yet (the upstream conformance check for this flow is also
// still skipped, pending the subscriptions/listen rewrite).
// Clients currently observe task state by polling `tasks/get`.
ServerNotification::TaskStatusNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"notifications/tasks",
));
}
ServerNotification::CustomNotification(_) => {
return Err(SubscriptionSendError::UnsupportedNotification(
"custom notification",
));
}
}
notification
.get_meta_mut()
.set_subscription_id(self.id.clone());
self.peer.send_notification(notification).await?;
Ok(())
}
/// Send `notifications/tools/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_tool_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ToolListChangedNotification(
ToolListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/prompts/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_prompt_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::PromptListChangedNotification(
PromptListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/resources/list_changed`.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_resource_list_changed(&self) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ResourceListChangedNotification(
ResourceListChangedNotification {
method: Default::default(),
extensions: Default::default(),
},
))
.await
}
/// Send `notifications/resources/updated` for an accepted URI.
///
/// # Errors
///
/// See [`Self::send`].
pub async fn notify_resource_updated(
&self,
uri: impl Into<String>,
) -> Result<(), SubscriptionSendError> {
self.send(ServerNotification::ResourceUpdatedNotification(
ResourceUpdatedNotification::new(ResourceUpdatedNotificationParam::new(uri)),
))
.await
}
}
/// Context for one established server-side notification subscription.
///
/// The acknowledgment has already been sent before this context is handed to
/// [`ServerHandler::listen`](crate::ServerHandler::listen).
#[derive(Debug)]
pub struct SubscriptionContext {
request: RequestContext<RoleServer>,
requested: SubscriptionFilter,
accepted: Arc<SubscriptionFilter>,
sink: SubscriptionSink,
_active_guard: DropGuard,
}
impl SubscriptionContext {
pub(crate) async fn establish(
request: RequestContext<RoleServer>,
requested: SubscriptionFilter,
accepted: SubscriptionFilter,
) -> Result<Self, ErrorData> {
let active = request.ct.child_token();
let accepted = Arc::new(accepted);
let sink = SubscriptionSink::new(
request.peer.clone(),
request.id.clone(),
accepted.clone(),
active.clone(),
);
let mut acknowledgment = SubscriptionsAcknowledgedNotification::new(
SubscriptionsAcknowledgedNotificationParams::new(accepted.as_ref().clone()),
);
let mut meta = NotificationMetaObject::new();
meta.set_subscription_id(request.id.clone());
acknowledgment.extensions.insert(meta);
request
.peer
.send_notification(ServerNotification::SubscriptionsAcknowledgedNotification(
acknowledgment,
))
.await
.map_err(|error| {
ErrorData::internal_error(
format!("failed to acknowledge subscription: {error}"),
None,
)
})?;
Ok(Self {
request,
requested,
accepted,
sink,
_active_guard: active.drop_guard(),
})
}
/// Return the filter requested by the client.
pub fn requested(&self) -> &SubscriptionFilter {
&self.requested
}
/// Return the subset accepted by the server.
pub fn accepted(&self) -> &SubscriptionFilter {
self.accepted.as_ref()
}
/// Return a cloneable, filter-enforcing notification sink.
pub fn sink(&self) -> &SubscriptionSink {
&self.sink
}
/// Wait until the subscription request is cancelled.
pub async fn cancelled(&self) {
self.request.ct.cancelled().await;
}
/// Access the underlying request context.
pub fn request_context(&self) -> &RequestContext<RoleServer> {
&self.request
}
}
impl<S: Service<RoleServer>> ServiceExt<RoleServer> for S {
fn serve_with_ct<T, E, A>(
self,
transport: T,
ct: CancellationToken,
) -> impl Future<Output = Result<RunningService<RoleServer, Self>, ServerInitializeError>>
+ MaybeSendFuture
where
T: IntoTransport<RoleServer, E, A>,
E: std::error::Error + Send + Sync + 'static,
Self: Sized,
{
serve_server_with_ct(self, transport, ct)
}
}
pub async fn serve_server<S, T, E, A>(
service: S,
transport: T,
) -> Result<RunningService<RoleServer, S>, ServerInitializeError>
where
S: Service<RoleServer>,
T: IntoTransport<RoleServer, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
serve_server_with_ct(service, transport, CancellationToken::new()).await
}
/// Helper function to get the next message from the stream
async fn expect_next_message<T>(
transport: &mut T,
context: &str,
) -> Result<ClientJsonRpcMessage, ServerInitializeError>
where
T: Transport<RoleServer>,
{
transport
.receive()
.await
.ok_or_else(|| ServerInitializeError::ConnectionClosed(context.to_string()))
}
pub async fn serve_server_with_ct<S, T, E, A>(
service: S,
transport: T,
ct: CancellationToken,
) -> Result<RunningService<RoleServer, S>, ServerInitializeError>
where
S: Service<RoleServer>,
T: IntoTransport<RoleServer, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
tokio::select! {
result = serve_server_with_ct_inner(service, transport.into_transport(), ct.clone()) => { result }
_ = ct.cancelled() => {
Err(ServerInitializeError::Cancelled)
}
}
}
/// Echoes the client-requested version if known; otherwise returns `server_fallback`.
pub(crate) fn negotiate_protocol_version(
client_requested: &ProtocolVersion,
server_fallback: ProtocolVersion,
) -> ProtocolVersion {
if ProtocolVersion::KNOWN_VERSIONS.contains(client_requested) {
client_requested.clone()
} else {
tracing::warn!(
client_requested = %client_requested,
server_fallback = %server_fallback,
"client requested unsupported protocol version; falling back to server default"
);
server_fallback
}
}
async fn serve_server_with_ct_inner<S, T>(
service: S,
transport: T,
ct: CancellationToken,
) -> Result<RunningService<RoleServer, S>, ServerInitializeError>
where
S: Service<RoleServer>,
T: Transport<RoleServer> + 'static,
{
let mut transport = transport.into_transport();
let id_provider = <Arc<AtomicU32RequestIdProvider>>::default();
// Get initialize request; the MCP spec permits ping before initialize.
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
let (request, id) = loop {
let msg = expect_next_message(&mut transport, "initialize request").await?;
match msg {
ClientJsonRpcMessage::Request(req)
if matches!(req.request, ClientRequest::PingRequest(_)) =>
{
transport
.send(ServerJsonRpcMessage::response(
ServerResult::EmptyResult(EmptyResult {}),
req.id,
))
.await
.map_err(|error| {
ServerInitializeError::transport::<T>(
error,
"sending pre-init ping response",
)
})?;
}
ClientJsonRpcMessage::Request(req) => break (req.request, req.id),
other => {
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
other,
)));
}
}
};
let initialize_request = match request {
ClientRequest::InitializeRequest(request) => request,
mut request => {
if !request
.get_meta()
.missing_required_keys(&ProtocolVersion::V_2026_07_28)
.is_empty()
{
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
ClientJsonRpcMessage::request(request, id),
)));
}
let (peer, peer_rx) = Peer::new(id_provider, None);
peer.require_request_metadata();
let context = RequestContext {
ct: ct.child_token(),
id: id.clone(),
meta: std::mem::take(request.get_meta_mut()),
extensions: std::mem::take(request.extensions_mut()),
peer: peer.clone(),
};
let response = match service.handle_request(request, context).await {
Ok(result) => ServerJsonRpcMessage::response(result, id),
Err(error) => ServerJsonRpcMessage::error(error, Some(id)),
};
transport.send(response).await.map_err(|error| {
ServerInitializeError::transport::<T>(error, "sending negotiated request response")
})?;
return Ok(serve_inner(service, transport, peer, peer_rx, ct));
}
};
let requested_protocol_version = initialize_request.params.protocol_version.clone();
let mut negotiated_peer_info = initialize_request.params.clone();
let (peer, peer_rx) = Peer::new(id_provider, Some(negotiated_peer_info.clone()));
let request = ClientRequest::InitializeRequest(initialize_request);
let context = RequestContext {
ct: ct.child_token(),
id: id.clone(),
meta: request.get_meta().clone(),
extensions: request.extensions().clone(),
peer: peer.clone(),
};
// Send initialize response
let init_response = service.handle_request(request, context).await;
let mut init_response = match init_response {
Ok(ServerResult::InitializeResult(init_response)) => init_response,
Ok(result) => {
return Err(ServerInitializeError::UnexpectedInitializeResponse(result));
}
Err(e) => {
transport
.send(ServerJsonRpcMessage::error(e.clone(), Some(id)))
.await
.map_err(|error| {
ServerInitializeError::transport::<T>(error, "sending error response")
})?;
return Err(ServerInitializeError::InitializeFailed(e));
}
};
init_response.protocol_version =
negotiate_protocol_version(&requested_protocol_version, init_response.protocol_version);
// Update peer_info so context.protocol_version() reflects the negotiated
// version in all subsequent request handlers.
negotiated_peer_info.protocol_version = init_response.protocol_version.clone();
peer.set_peer_info(negotiated_peer_info);
transport
.send(ServerJsonRpcMessage::response(
ServerResult::InitializeResult(init_response),
id,
))
.await
.map_err(|error| {
ServerInitializeError::transport::<T>(error, "sending initialize response")
})?;
// Enter the main service loop immediately after sending InitializeResult.
// The initialized notification will be handled as a regular notification by serve_inner.
// This matches the TypeScript SDK behavior: no init gate, no waiting for initialized.
// Streamable HTTP has no ordering guarantee between POSTs, and the MCP spec uses
// SHOULD NOT (not MUST NOT) for pre-initialized messages, so any request arriving
// before initialized is processed normally.
Ok(serve_inner(service, transport, peer, peer_rx, ct))
}
macro_rules! method {
($(#[$meta:meta])* peer_req $method:ident $Req:ident() => $Resp: ident ) => {
$(#[$meta])*
pub async fn $method(&self) -> Result<$Resp, ServiceError> {
let result = self
.send_request(ServerRequest::$Req($Req {
method: Default::default(),
extensions: Default::default(),
}))
.await?;
match result {
ClientResult::$Resp(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
};
($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident) => $Resp: ident ) => {
$(#[$meta])*
pub async fn $method(&self, params: $Param) -> Result<$Resp, ServiceError> {
let result = self
.send_request(ServerRequest::$Req($Req {
method: Default::default(),
params,
extensions: Default::default(),
}))
.await?;
match result {
ClientResult::$Resp(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
};
($(#[$meta:meta])* peer_req $method:ident $Req:ident($Param: ident)) => {
$(#[$meta])*
pub fn $method(
&self,
params: $Param,
) -> impl Future<Output = Result<(), ServiceError>> + Send + '_ {
async move {
let result = self
.send_request(ServerRequest::$Req($Req {
method: Default::default(),
params,
}))
.await?;
match result {
ClientResult::EmptyResult(_) => Ok(()),
_ => Err(ServiceError::UnexpectedResponse),
}
}
}
};
($(#[$meta:meta])* peer_not $method:ident $Not:ident($Param: ident)) => {
$(#[$meta])*
pub async fn $method(&self, params: $Param) -> Result<(), ServiceError> {
self.send_notification(ServerNotification::$Not($Not {
method: Default::default(),
params,
extensions: Default::default(),
}))
.await?;
Ok(())
}
};
($(#[$meta:meta])* peer_not $method:ident $Not:ident) => {
$(#[$meta])*
pub async fn $method(&self) -> Result<(), ServiceError> {
self.send_notification(ServerNotification::$Not($Not {
method: Default::default(),
extensions: Default::default(),
}))
.await?;
Ok(())
}
};
// Timeout-only variants (base method should be created separately with peer_req)
($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident() => $Resp: ident) => {
$(#[$meta])*
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,
reset_timeout_on_progress: false,
max_total_timeout: None,
};
let result = self
.send_request_with_option(request, options)
.await?
.await_response()
.await?;
match result {
ClientResult::$Resp(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
};
($(#[$meta:meta])* peer_req_with_timeout $method_with_timeout:ident $Req:ident($Param: ident) => $Resp: ident) => {
$(#[$meta])*
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,
reset_timeout_on_progress: false,
max_total_timeout: 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> {
/// Check if the client supports sampling tools capability.
pub fn supports_sampling_tools(&self) -> bool {
if let Some(client_info) = self.peer_info() {
client_info
.capabilities
.sampling
.as_ref()
.and_then(|s| s.tools.as_ref())
.is_some()
} else {
false
}
}
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
#[deprecated(
since = "1.8.0",
note = "Sampling is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
)]
pub async fn create_message(
&self,
params: CreateMessageRequestParams,
) -> Result<CreateMessageResult, ServiceError> {
// MUST throw error when tools/toolChoice provided without capability
if (params.tools.is_some() || params.tool_choice.is_some())
&& !self.supports_sampling_tools()
{
return Err(ServiceError::McpError(ErrorData::invalid_params(
"tools or toolChoice provided but client does not support sampling tools capability",
None,
)));
}
// Validate message structure
params
.validate()
.map_err(|e| ServiceError::McpError(ErrorData::invalid_params(e, None)))?;
let result = self
.send_request(ServerRequest::CreateMessageRequest(CreateMessageRequest {
method: Default::default(),
params,
extensions: Default::default(),
}))
.await?;
match result {
ClientResult::CreateMessageResult(result) => Ok(*result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
method!(
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
#[deprecated(
since = "1.8.0",
note = "Roots is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
)]
peer_req list_roots ListRootsRequest() => ListRootsResult
);
#[cfg(feature = "elicitation")]
method!(
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult
);
#[cfg(feature = "elicitation")]
method!(
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult
);
method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam));
method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam));
method!(
#[deprecated(
since = "1.8.0",
note = "Logging is deprecated by SEP-2577 and will be removed in a future release. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577"
)]
peer_not notify_logging_message LoggingMessageNotification(LoggingMessageNotificationParam)
);
method!(peer_not notify_resource_updated ResourceUpdatedNotification(ResourceUpdatedNotificationParam));
method!(peer_not notify_resource_list_changed ResourceListChangedNotification);
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)]
#[non_exhaustive]
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 {}
)*
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ElicitationMode {
Form,
Url,
}
#[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 supported_elicitation_modes(&self) -> HashSet<ElicitationMode> {
if let Some(client_info) = self.peer_info() {
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 {
HashSet::new()
}
}
/// 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(())
/// # }
/// ```
///
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
#[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(())
/// # }
/// ```
///
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
#[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 form elicitation capability
if !self
.supported_elicitation_modes()
.contains(&ElicitationMode::Form)
{
return Err(ElicitationError::CapabilityNotSupported);
}
// Generate schema automatically from type
let schema = crate::model::ElicitationSchema::from_type::<T>().map_err(|e| {
ElicitationError::Service(ServiceError::McpError(crate::ErrorData::invalid_params(
format!(
"Invalid schema for type {}: {}",
std::any::type_name::<T>(),
e
),
None,
)))
})?;
let response = self
.create_elicitation_with_timeout(
ElicitRequestParams::FormElicitationParams {
meta: None,
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),
}
}
/// 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<RoleServer>) -> Result<(), Box<dyn std::error::Error>> {
/// 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(())
/// }
/// ```
///
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
#[cfg(feature = "elicitation")]
pub async fn elicit_url(
&self,
message: impl Into<String>,
url: impl Into<Url>,
elicitation_id: impl Into<String>,
) -> Result<ElicitationAction, ElicitationError> {
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<RoleServer>) -> Result<(), Box<dyn std::error::Error>> {
/// 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(())
/// }
/// ```
///
/// # SEP-2260: request association
///
/// From protocol version `2026-07-28` this must be issued while handling a
/// client request; see [`OriginatingRequestId`].
#[cfg(feature = "elicitation")]
pub async fn elicit_url_with_timeout(
&self,
message: impl Into<String>,
url: impl Into<Url>,
elicitation_id: impl Into<String>,
timeout: Option<std::time::Duration>,
) -> Result<ElicitationAction, ElicitationError> {
// 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(
ElicitRequestParams::UrlElicitationParams {
meta: None,
message: message.into(),
url: url.into().to_string(),
elicitation_id: elicitation_id.into(),
},
timeout,
)
.await?
.action;
Ok(action)
}
}