fix: handle declined MRTR elicitation (#1151)
This commit is contained in:
parent
f9b39f3c13
commit
018ab24fac
1 changed files with 149 additions and 16 deletions
|
|
@ -38,6 +38,7 @@ use rmcp::{
|
||||||
model::*,
|
model::*,
|
||||||
service::{RequestContext, RoleClient, RoleServer},
|
service::{RequestContext, RoleClient, RoleServer},
|
||||||
};
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
/// A stable, high-entropy secret. In a real deployment, load this from your
|
/// A stable, high-entropy secret. In a real deployment, load this from your
|
||||||
|
|
@ -45,6 +46,17 @@ use serde_json::json;
|
||||||
/// the lifetime of any in-flight MRTR exchange.
|
/// the lifetime of any in-flight MRTR exchange.
|
||||||
const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!";
|
const REQUEST_STATE_KEY: &[u8] = b"example-request-state-signing-key-32b!";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct WeatherRequestState {
|
||||||
|
awaiting: WeatherInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum WeatherInput {
|
||||||
|
City,
|
||||||
|
}
|
||||||
|
|
||||||
/// A server that needs a city name before it can answer a weather query.
|
/// A server that needs a city name before it can answer a weather query.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct WeatherServer {
|
struct WeatherServer {
|
||||||
|
|
@ -59,6 +71,47 @@ impl Default for WeatherServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WeatherServer {
|
||||||
|
fn open_request_state(&self, sealed: &str) -> Result<WeatherRequestState, ErrorData> {
|
||||||
|
self.codec
|
||||||
|
.open_json(sealed)
|
||||||
|
.map_err(|_| ErrorData::invalid_params("tampered or unknown request state", None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_weather_request(
|
||||||
|
input_responses: Option<&InputResponses>,
|
||||||
|
) -> Result<CallToolResult, ErrorData> {
|
||||||
|
let response = input_responses
|
||||||
|
.and_then(|responses| responses.get("city"))
|
||||||
|
.ok_or_else(|| ErrorData::invalid_params("missing city elicitation response", None))?;
|
||||||
|
let response = ElicitResult::deserialize(response)
|
||||||
|
.map_err(|_| ErrorData::invalid_params("invalid city elicitation response", None))?;
|
||||||
|
|
||||||
|
let message = match response.action {
|
||||||
|
ElicitationAction::Accept => {
|
||||||
|
let city = response
|
||||||
|
.content
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|content| content["city"].as_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ErrorData::invalid_params("accepted elicitation did not include a city", None)
|
||||||
|
})?;
|
||||||
|
format!("It is sunny in {city}.")
|
||||||
|
}
|
||||||
|
ElicitationAction::Decline => "I cannot look up the weather without a city.".into(),
|
||||||
|
ElicitationAction::Cancel => "Weather lookup cancelled.".into(),
|
||||||
|
_ => {
|
||||||
|
return Err(ErrorData::invalid_params(
|
||||||
|
"unsupported elicitation action",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CallToolResult::success(vec![ContentBlock::text(message)]))
|
||||||
|
}
|
||||||
|
|
||||||
impl ServerHandler for WeatherServer {
|
impl ServerHandler for WeatherServer {
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerInfo {
|
||||||
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
|
let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build());
|
||||||
|
|
@ -78,7 +131,9 @@ impl ServerHandler for WeatherServer {
|
||||||
None => {
|
None => {
|
||||||
let sealed = self
|
let sealed = self
|
||||||
.codec
|
.codec
|
||||||
.seal_json(&json!({ "awaiting": "city" }))
|
.seal_json(&WeatherRequestState {
|
||||||
|
awaiting: WeatherInput::City,
|
||||||
|
})
|
||||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||||
|
|
||||||
let mut input_requests = InputRequests::new();
|
let mut input_requests = InputRequests::new();
|
||||||
|
|
@ -103,21 +158,11 @@ impl ServerHandler for WeatherServer {
|
||||||
// Retry round: verify the echoed state before trusting it, read the
|
// Retry round: verify the echoed state before trusting it, read the
|
||||||
// elicited city, and return the final result.
|
// elicited city, and return the final result.
|
||||||
Some(sealed) => {
|
Some(sealed) => {
|
||||||
let _state: serde_json::Value = self.codec.open_json(&sealed).map_err(|_| {
|
let state = self.open_request_state(&sealed)?;
|
||||||
ErrorData::invalid_params("tampered or unknown request state", None)
|
match state.awaiting {
|
||||||
})?;
|
WeatherInput::City => finish_weather_request(request.input_responses.as_ref())
|
||||||
|
.map(CallToolResponse::from),
|
||||||
let city = request
|
}
|
||||||
.input_responses
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|r| r.get("city"))
|
|
||||||
.and_then(|v| v["content"]["city"].as_str())
|
|
||||||
.unwrap_or("your area");
|
|
||||||
|
|
||||||
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
|
||||||
"It is sunny in {city}."
|
|
||||||
))])
|
|
||||||
.into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,3 +242,91 @@ async fn main() -> anyhow::Result<()> {
|
||||||
client.cancel().await?;
|
client.cancel().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn input_responses(response: ElicitResult) -> InputResponses {
|
||||||
|
[("city".into(), serde_json::to_value(response).unwrap())].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_weather_request_uses_city_when_elicitation_is_accepted() {
|
||||||
|
let responses = input_responses(
|
||||||
|
ElicitResult::new(ElicitationAction::Accept).with_content(json!({ "city": "Paris" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = finish_weather_request(Some(&responses)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.content[0].as_text().unwrap().text,
|
||||||
|
"It is sunny in Paris."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_weather_request_ignores_content_when_elicitation_is_declined() {
|
||||||
|
let responses = input_responses(
|
||||||
|
ElicitResult::new(ElicitationAction::Decline).with_content(json!({ "city": "Paris" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = finish_weather_request(Some(&responses)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.content[0].as_text().unwrap().text,
|
||||||
|
"I cannot look up the weather without a city."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_weather_request_stops_lookup_when_elicitation_is_cancelled() {
|
||||||
|
let responses = input_responses(ElicitResult::new(ElicitationAction::Cancel));
|
||||||
|
|
||||||
|
let result = finish_weather_request(Some(&responses)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.content[0].as_text().unwrap().text,
|
||||||
|
"Weather lookup cancelled."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_weather_request_rejects_accepted_elicitation_without_city() {
|
||||||
|
let responses =
|
||||||
|
input_responses(ElicitResult::new(ElicitationAction::Accept).with_content(json!({})));
|
||||||
|
|
||||||
|
let error = finish_weather_request(Some(&responses)).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ErrorData::invalid_params("accepted elicitation did not include a city", None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_weather_request_rejects_missing_city_response() {
|
||||||
|
let error = finish_weather_request(None).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ErrorData::invalid_params("missing city elicitation response", None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_state_rejects_unknown_phase() {
|
||||||
|
let server = WeatherServer::default();
|
||||||
|
let sealed = server
|
||||||
|
.codec
|
||||||
|
.seal_json(&json!({ "awaiting": "country" }))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = server.open_request_state(&sealed).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ErrorData::invalid_params("tampered or unknown request state", None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue