use rmcp::RoleServer;
use rmcp::handler::server::tool::IntoCallToolResult;
use rmcp::model::{
CallToolResponse, CallToolResult, ClientCapabilities, ContentBlock, ElicitRequestParams, ErrorData, InputRequest,
InputRequests, InputRequiredResult, InputResponses, ProtocolVersion,
};
use rmcp::service::RequestContext;
use serde::de::DeserializeOwned;
pub const ELICITATION_UNSUPPORTED: &str = "This tool needs to ask the user for input, but the connected client does not support \
interactive input (MCP elicitation over protocol 2026-07-28 or newer).";
#[derive(Debug)]
pub enum MrtrAction {
Request(InputRequiredResult),
Resume(InputResponses),
Abort(AbortReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AbortReason {
UnsupportedInput { key: String, kind: InputKind },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputKind {
FormElicitation,
UrlElicitation,
Sampling,
Roots,
Unknown,
}
impl MrtrAction {
pub fn validate_for_client(self, context: &RequestContext<RoleServer>) -> Self {
let Self::Request(result) = &self else {
return self;
};
let Some(requests) = result.input_requests.as_ref() else {
return self;
};
let capabilities = context.client_capabilities();
match validate_input_requests(context.protocol_version().as_ref(), capabilities.as_ref(), requests) {
Ok(()) => self,
Err(reason) => Self::Abort(reason),
}
}
}
impl AbortReason {
pub fn message(&self) -> String {
match self {
Self::UnsupportedInput { key, kind } => {
format!("Client does not support {kind} required by input request '{key}'")
}
}
}
}
impl std::fmt::Display for InputKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::FormElicitation => "form elicitation",
Self::UrlElicitation => "URL elicitation",
Self::Sampling => "sampling",
Self::Roots => "roots",
Self::Unknown => "this input kind",
};
f.write_str(name)
}
}
#[allow(clippy::large_enum_variant)]
pub enum Elicited<T> {
Respond(CallToolResponse),
Done(T),
}
impl<T> Elicited<T> {
pub fn error(message: impl Into<String>) -> Self {
Self::Respond(CallToolResult::error(vec![ContentBlock::text(message.into())]).into())
}
}
impl<T: IntoCallToolResult> IntoCallToolResult for Elicited<T> {
fn into_call_tool_result(self) -> Result<CallToolResponse, ErrorData> {
match self {
Self::Respond(response) => Ok(response),
Self::Done(value) => value.into_call_tool_result(),
}
}
}
pub fn get_next_mrtr_action(
responses: Option<&InputResponses>,
make_requests: impl FnOnce() -> Result<InputRequests, ErrorData>,
) -> Result<MrtrAction, ErrorData> {
if let Some(responses) = responses {
return Ok(MrtrAction::Resume(responses.clone()));
}
let requests = make_requests()?;
if requests.is_empty() {
return Err(ErrorData::internal_error("MRTR request batch must not be empty", None));
}
Ok(MrtrAction::Request(InputRequiredResult::from_input_requests(requests)))
}
pub fn validate_input_requests(
protocol_version: Option<&ProtocolVersion>,
capabilities: Option<&ClientCapabilities>,
requests: &InputRequests,
) -> Result<(), AbortReason> {
let version_supported = protocol_version.is_some_and(|version| version >= &ProtocolVersion::V_2026_07_28);
for (key, request) in requests {
let kind = input_kind(request);
let supported =
version_supported && capabilities.is_some_and(|capabilities| supports_input(capabilities, kind));
if !supported {
return Err(AbortReason::UnsupportedInput { key: key.clone(), kind });
}
}
Ok(())
}
pub fn parse_response<T: DeserializeOwned>(responses: &InputResponses, key: &str) -> Result<T, ErrorData> {
let response = responses
.get(key)
.ok_or_else(|| ErrorData::invalid_params(format!("missing input response for '{key}'"), None))?;
serde_json::from_value(response.clone())
.map_err(|e| ErrorData::invalid_params(format!("invalid input response for '{key}': {e}"), None))
}
fn supports_input(capabilities: &ClientCapabilities, kind: InputKind) -> bool {
match kind {
InputKind::FormElicitation => capabilities
.elicitation
.as_ref()
.is_some_and(|elicitation| elicitation.form.is_some() || elicitation.url.is_none()),
InputKind::UrlElicitation => {
capabilities.elicitation.as_ref().is_some_and(|elicitation| elicitation.url.is_some())
}
InputKind::Sampling => capabilities.sampling.is_some(),
InputKind::Roots => capabilities.roots.is_some(),
InputKind::Unknown => false,
}
}
fn input_kind(request: &InputRequest) -> InputKind {
#[allow(unreachable_patterns)]
match request {
InputRequest::CreateMessage(_) => InputKind::Sampling,
InputRequest::Elicitation(request) => elicitation_kind(&request.params),
InputRequest::ListRoots(_) => InputKind::Roots,
_ => InputKind::Unknown,
}
}
fn elicitation_kind(params: &ElicitRequestParams) -> InputKind {
#[allow(unreachable_patterns)]
match params {
ElicitRequestParams::FormElicitationParams { .. } => InputKind::FormElicitation,
ElicitRequestParams::UrlElicitationParams { .. } => InputKind::UrlElicitation,
_ => InputKind::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{
ClientCapabilities, ElicitRequest, ElicitRequestParams, ElicitationCapability, ElicitationSchema,
FormElicitationCapability, InputRequest, UrlElicitationCapability,
};
use serde_json::json;
#[test]
fn mrtr_step_requests_the_complete_batch() {
let requests = InputRequests::from([
("first".to_string(), elicitation("First")),
("second".to_string(), elicitation("Second")),
]);
let action = get_next_mrtr_action(None, || Ok::<_, ErrorData>(requests)).unwrap();
let MrtrAction::Request(result) = action else {
panic!("expected an input request batch");
};
let requests = result.input_requests.expect("requests should be present");
assert_eq!(requests.len(), 2);
assert!(requests.contains_key("first"));
assert!(requests.contains_key("second"));
}
#[test]
fn mrtr_step_resumes_with_the_complete_batch_without_building_requests() {
let responses = InputResponses::from([
("first".to_string(), json!({ "value": 1 })),
("second".to_string(), json!({ "value": 2 })),
]);
let action = get_next_mrtr_action(Some(&responses), || -> Result<InputRequests, ErrorData> {
panic!("request construction should be lazy on a retry")
})
.unwrap();
let MrtrAction::Resume(actual) = action else {
panic!("expected the complete response batch");
};
assert_eq!(actual, responses);
}
#[test]
fn an_empty_response_batch_is_still_a_retry() {
let action = get_next_mrtr_action(Some(&InputResponses::new()), || -> Result<InputRequests, ErrorData> {
panic!("an empty retry must not issue the requests again")
})
.unwrap();
assert!(matches!(action, MrtrAction::Resume(responses) if responses.is_empty()));
}
#[test]
fn validates_capabilities_against_every_request_in_the_batch() {
let requests = InputRequests::from([
("form".to_string(), elicitation("Form")),
("url".to_string(), url_elicitation("URL")),
]);
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability::new().with_form(FormElicitationCapability::new()));
let error =
validate_input_requests(Some(&ProtocolVersion::V_2026_07_28), Some(&capabilities), &requests).unwrap_err();
assert_eq!(error, AbortReason::UnsupportedInput { key: "url".to_string(), kind: InputKind::UrlElicitation });
}
#[test]
fn validates_a_mixed_elicitation_batch_when_all_capabilities_are_present() {
let requests = InputRequests::from([
("form".to_string(), elicitation("Form")),
("url".to_string(), url_elicitation("URL")),
]);
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(
ElicitationCapability::new()
.with_form(FormElicitationCapability::new())
.with_url(UrlElicitationCapability::new()),
);
validate_input_requests(Some(&ProtocolVersion::V_2026_07_28), Some(&capabilities), &requests).unwrap();
}
#[test]
fn rejects_input_requests_for_an_old_protocol_version() {
let requests = InputRequests::from([("form".to_string(), elicitation("Form"))]);
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability::new().with_form(FormElicitationCapability::new()));
let error =
validate_input_requests(Some(&ProtocolVersion::V_2025_11_25), Some(&capabilities), &requests).unwrap_err();
assert_eq!(error, AbortReason::UnsupportedInput { key: "form".to_string(), kind: InputKind::FormElicitation });
}
#[test]
fn parse_response_deserializes_a_key_from_the_batch() {
let responses = InputResponses::from([("answer".to_string(), json!(42))]);
assert_eq!(parse_response::<u64>(&responses, "answer").unwrap(), 42);
}
#[test]
fn parse_response_rejects_a_missing_key() {
let error = parse_response::<u64>(&InputResponses::new(), "answer").unwrap_err();
assert_eq!(error.message, "missing input response for 'answer'");
}
fn elicitation(message: &str) -> InputRequest {
InputRequest::Elicitation(ElicitRequest::new(ElicitRequestParams::FormElicitationParams {
meta: None,
message: message.to_string(),
requested_schema: ElicitationSchema::builder().build().unwrap(),
}))
}
fn url_elicitation(message: &str) -> InputRequest {
InputRequest::Elicitation(ElicitRequest::new(ElicitRequestParams::UrlElicitationParams {
meta: None,
message: message.to_string(),
url: "https://example.com/input".to_string(),
elicitation_id: "id".to_string(),
}))
}
}