use std::collections::BTreeMap;
use mcp_conformance_core::trace::Direction;
use serde_json::{Map, Value};
use super::super::FindingSink;
use crate::context::TraceContext;
#[cfg(test)]
mod tests;
const SUPPORTED: &[&str] = &["prompts/get", "resources/read", "tools/call"];
const INPUT_REQUEST_METHODS: &[&str] =
&["elicitation/create", "sampling/createMessage", "roots/list"];
const INPUT_REQUIRED: &str = "input_required";
#[derive(Debug, Clone, Copy)]
struct Round<'a> {
seq: u64,
origin: (u64, &'a Value, &'a str),
requests: Option<&'a Map<String, Value>>,
state: Option<&'a Value>,
}
#[derive(Debug, Clone, Copy)]
struct Retry<'a> {
seq: u64,
id: &'a Value,
method: &'a str,
responses: Option<&'a Map<String, Value>>,
state: Option<&'a Value>,
}
fn rounds<'a>(context: &'a TraceContext<'_>) -> Vec<Round<'a>> {
context
.exchanges()
.filter_map(|exchange| {
let result = exchange.result?;
if result.get("resultType").and_then(Value::as_str) != Some(INPUT_REQUIRED) {
return None;
}
let id = exchange.request.message_payload()?.get("id")?;
Some(Round {
seq: exchange.response.seq,
origin: (exchange.request.seq, id, exchange.method),
requests: result.get("inputRequests").and_then(Value::as_object),
state: result.get("requestState"),
})
})
.collect()
}
fn retries<'a>(context: &'a TraceContext<'_>) -> Vec<Retry<'a>> {
context
.messages()
.filter_map(|(event, _, _)| {
if event.direction != Direction::ClientToServer {
return None;
}
let payload = event.message_payload()?;
let method = payload.get("method")?.as_str()?;
let id = payload.get("id").filter(|id| !id.is_null())?;
let params = payload.get("params")?;
let responses = params.get("inputResponses").and_then(Value::as_object);
let state = params.get("requestState");
(responses.is_some() || state.is_some()).then_some(Retry {
seq: event.seq,
id,
method,
responses,
state,
})
})
.collect()
}
fn retries_with_rounds<'a>(context: &'a TraceContext<'_>) -> Vec<(Retry<'a>, Option<Round<'a>>)> {
let rounds: BTreeMap<u64, Round<'a>> = rounds(context)
.into_iter()
.map(|round| (round.seq, round))
.collect();
let retries: BTreeMap<u64, Retry<'a>> = retries(context)
.into_iter()
.map(|retry| (retry.seq, retry))
.collect();
let mut latest: Option<Round<'a>> = None;
let mut out = Vec::new();
for (event, _, _) in context.messages() {
if let Some(round) = rounds.get(&event.seq) {
latest = Some(*round);
} else if let Some(retry) = retries.get(&event.seq) {
out.push((*retry, latest));
}
}
out
}
pub(in crate::checks) fn input_required_supported_methods(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for round in rounds(context) {
sink.examined();
let (_, _, method) = round.origin;
if !SUPPORTED.contains(&method) {
sink.push(
Some(round.seq),
format!(
"`input_required` answers `{method}`; this revision permits it only on \
{}",
SUPPORTED.join(", ")
),
);
}
}
}
pub(in crate::checks) fn input_request_methods(context: &TraceContext<'_>, sink: &mut FindingSink) {
for round in rounds(context) {
let Some(requests) = round.requests else {
continue;
};
for (key, request) in requests {
sink.examined();
match request.get("method").and_then(Value::as_str) {
Some(method) if INPUT_REQUEST_METHODS.contains(&method) => {}
Some(method) => sink.push(
Some(round.seq),
format!(
"`inputRequests[{key}]` asks for `{method}`, which is not one of \
ElicitRequest, CreateMessageRequest or ListRootsRequest"
),
),
None => sink.push(
Some(round.seq),
format!("`inputRequests[{key}]` is not a request object with a `method`"),
),
}
}
}
}
pub(in crate::checks) fn input_required_has_content(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for round in rounds(context) {
sink.examined();
if round.requests.is_none() && round.state.is_none() {
sink.push(
Some(round.seq),
"`input_required` carries neither `inputRequests` nor `requestState`, so the \
round it opens cannot be completed"
.to_owned(),
);
}
}
}
pub(in crate::checks) fn retry_carries_input_responses(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (retry, round) in retries_with_rounds(context) {
let Some(round) = round else {
continue;
};
if round.requests.is_none_or(Map::is_empty) {
continue;
}
sink.examined();
for key in missing_keys(&round, &retry) {
sink.push(
Some(retry.seq),
format!(
"the retry carries no `inputResponses[{key}]` for the input the \
`input_required` at seq {} asked for",
round.seq
),
);
}
}
}
fn missing_keys(round: &Round<'_>, retry: &Retry<'_>) -> Vec<String> {
let Some(requests) = round.requests else {
return Vec::new();
};
requests
.keys()
.filter(|key| {
!retry
.responses
.is_some_and(|responses| responses.contains_key(*key))
})
.cloned()
.collect()
}
pub(in crate::checks) fn request_state_echoed(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (retry, round) in retries_with_rounds(context) {
let Some(round) = round else {
continue;
};
let Some(issued) = round.state else { continue };
sink.examined();
match retry.state {
Some(echoed) if echoed == issued => {}
Some(echoed) => sink.push(
Some(retry.seq),
format!(
"the retry echoes `requestState` {echoed} instead of the {issued} the \
`input_required` at seq {} issued",
round.seq
),
),
None => sink.push(
Some(retry.seq),
format!(
"the retry omits the `requestState` the `input_required` at seq {} \
issued, which it must echo back exactly",
round.seq
),
),
}
}
}
pub(in crate::checks) fn no_unsolicited_request_state(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (retry, round) in retries_with_rounds(context) {
if retry.state.is_none() {
continue;
}
sink.examined();
let issued = round.and_then(|round| round.state);
if issued.is_none() {
sink.push(
Some(retry.seq),
"the request carries a `requestState` that no `input_required` before it \
issued"
.to_owned(),
);
}
}
}
pub(in crate::checks) fn retry_id_differs(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (retry, round) in retries_with_rounds(context) {
let Some(round) = round else {
continue;
};
sink.examined();
let (origin_seq, origin_id, _) = round.origin;
if retry.id == origin_id {
sink.push(
Some(retry.seq),
format!(
"the retry reuses id {origin_id} from the request at seq {origin_seq}; \
the two are independent requests and must not share one"
),
);
}
}
}
pub(in crate::checks) fn request_state_scoped_to_retry(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let issued: BTreeMap<String, &str> = rounds(context)
.iter()
.filter_map(|round| round.state.map(|state| (state.to_string(), round.origin.2)))
.collect();
for retry in retries(context) {
let Some(state) = retry.state else { continue };
let Some(&origin_method) = issued.get(&state.to_string()) else {
continue;
};
sink.examined();
if retry.method != origin_method {
sink.push(
Some(retry.seq),
format!(
"`{}` carries the `requestState` issued for a `{origin_method}` request; \
it affects only that request's retry",
retry.method
),
);
}
}
}
pub(in crate::checks) fn missing_input_reasked(context: &TraceContext<'_>, sink: &mut FindingSink) {
let paired: BTreeMap<u64, (Retry<'_>, Option<Round<'_>>)> = retries_with_rounds(context)
.into_iter()
.map(|(retry, round)| (retry.seq, (retry, round)))
.collect();
for exchange in context.exchanges() {
let Some((retry, Some(round))) = paired.get(&exchange.request.seq).copied() else {
continue;
};
let missing = missing_keys(&round, &retry);
if missing.is_empty() {
continue; }
sink.examined();
if exchange.result.is_some() {
continue;
}
sink.push(
Some(exchange.response.seq),
format!(
"the retry omitted {} that the `input_required` at seq {} asked for, and the \
server answered with an error rather than asking again",
missing
.iter()
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>()
.join(", "),
round.seq
),
);
}
}