Skip to main content

mcp_trace_validator/checks/draft/
mrtr.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Multi Round-Trip Requests: the pattern that replaced server-initiated requests.
5//!
6//! A round is two independent JSON-RPC requests. The server answers the first
7//! with `resultType: "input_required"` carrying an `inputRequests` map, an opaque
8//! `requestState`, or both; the client gathers what was asked for and sends a
9//! *new* request — different id — carrying `inputResponses` and echoing the
10//! state back.
11//!
12//! **How a retry is identified, and why it matters.** Nothing in the protocol
13//! labels a request as a retry, so these checks use the two fields that exist
14//! only for that purpose: a client request carrying `inputResponses` or
15//! `requestState` is a retry, of the most recent `input_required` before it.
16//! That is exact for a serial session and is the only correlation the wire
17//! supports; the specification itself contemplates parallel requests
18//! (MRTR-020), and where a session interleaves rounds these checks would pair a
19//! retry with the wrong round. A request carrying neither field is never treated
20//! as a retry, which is what keeps an ordinary follow-up request — a second
21//! `tools/call` for something else entirely — from being judged as one.
22//!
23//! Ten of the page's clauses carry exclusions rather than checks, and they
24//! cluster on one thing: `requestState` is opaque *by design*. Whether it is
25//! integrity-protected, what it contains, and whether the server validated it
26//! are all invisible to a recording that carries only the blob.
27
28use std::collections::BTreeMap;
29
30use mcp_conformance_core::trace::Direction;
31use serde_json::{Map, Value};
32
33use super::super::FindingSink;
34use crate::context::TraceContext;
35
36#[cfg(test)]
37mod tests;
38
39/// The client requests that may draw an `InputRequiredResult` (#supported-requests).
40const SUPPORTED: &[&str] = &["prompts/get", "resources/read", "tools/call"];
41
42/// The request objects an `inputRequests` value may be.
43const INPUT_REQUEST_METHODS: &[&str] =
44    &["elicitation/create", "sampling/createMessage", "roots/list"];
45
46/// The `resultType` that marks a round as incomplete.
47const INPUT_REQUIRED: &str = "input_required";
48
49/// An `InputRequiredResult` and the request it answered.
50#[derive(Debug, Clone, Copy)]
51struct Round<'a> {
52    /// The `seq` of the result.
53    seq: u64,
54    /// The originating request's `seq`, `id` text and `method`.
55    origin: (u64, &'a Value, &'a str),
56    /// The `inputRequests` map, when the result carried one.
57    requests: Option<&'a Map<String, Value>>,
58    /// The `requestState` blob, when the result carried one.
59    state: Option<&'a Value>,
60}
61
62/// A client request that identifies itself as a retry.
63#[derive(Debug, Clone, Copy)]
64struct Retry<'a> {
65    seq: u64,
66    id: &'a Value,
67    method: &'a str,
68    responses: Option<&'a Map<String, Value>>,
69    state: Option<&'a Value>,
70}
71
72/// Every `input_required` answer in the trace, paired with its originating request.
73///
74/// Driven from exchanges, so a result whose request is not in the recording is
75/// skipped: without the request there is no method to judge against and no id to
76/// compare a retry's against.
77fn rounds<'a>(context: &'a TraceContext<'_>) -> Vec<Round<'a>> {
78    context
79        .exchanges()
80        .filter_map(|exchange| {
81            let result = exchange.result?;
82            if result.get("resultType").and_then(Value::as_str) != Some(INPUT_REQUIRED) {
83                return None;
84            }
85            let id = exchange.request.message_payload()?.get("id")?;
86            Some(Round {
87                seq: exchange.response.seq,
88                origin: (exchange.request.seq, id, exchange.method),
89                requests: result.get("inputRequests").and_then(Value::as_object),
90                state: result.get("requestState"),
91            })
92        })
93        .collect()
94}
95
96/// Every client request carrying a retry's marker fields, in trace order.
97fn retries<'a>(context: &'a TraceContext<'_>) -> Vec<Retry<'a>> {
98    context
99        .messages()
100        .filter_map(|(event, _, _)| {
101            if event.direction != Direction::ClientToServer {
102                return None;
103            }
104            let payload = event.message_payload()?;
105            let method = payload.get("method")?.as_str()?;
106            let id = payload.get("id").filter(|id| !id.is_null())?;
107            let params = payload.get("params")?;
108            let responses = params.get("inputResponses").and_then(Value::as_object);
109            let state = params.get("requestState");
110            (responses.is_some() || state.is_some()).then_some(Retry {
111                seq: event.seq,
112                id,
113                method,
114                responses,
115                state,
116            })
117        })
118        .collect()
119}
120
121/// Each retry paired with the round it answers: the most recent one before it.
122///
123/// One ordered pass rather than a `round.seq < retry.seq` comparison. A round is
124/// a server *result* and a retry a client *request*, so no two can share a `seq`
125/// — which makes `<` and `<=` indistinguishable by construction, a difference no
126/// trace could ever exhibit and therefore no test could ever catch. Walking the
127/// messages in order states the intent directly instead.
128fn retries_with_rounds<'a>(context: &'a TraceContext<'_>) -> Vec<(Retry<'a>, Option<Round<'a>>)> {
129    let rounds: BTreeMap<u64, Round<'a>> = rounds(context)
130        .into_iter()
131        .map(|round| (round.seq, round))
132        .collect();
133    let retries: BTreeMap<u64, Retry<'a>> = retries(context)
134        .into_iter()
135        .map(|retry| (retry.seq, retry))
136        .collect();
137    let mut latest: Option<Round<'a>> = None;
138    let mut out = Vec::new();
139    for (event, _, _) in context.messages() {
140        if let Some(round) = rounds.get(&event.seq) {
141            latest = Some(*round);
142        } else if let Some(retry) = retries.get(&event.seq) {
143            out.push((*retry, latest));
144        }
145    }
146    out
147}
148
149/// `MRTR-004`: `InputRequiredResult` answers only the three supported requests.
150pub(in crate::checks) fn input_required_supported_methods(
151    context: &TraceContext<'_>,
152    sink: &mut FindingSink,
153) {
154    for round in rounds(context) {
155        sink.examined();
156        let (_, _, method) = round.origin;
157        if !SUPPORTED.contains(&method) {
158            sink.push(
159                Some(round.seq),
160                format!(
161                    "`input_required` answers `{method}`; this revision permits it only on \
162                     {}",
163                    SUPPORTED.join(", ")
164                ),
165            );
166        }
167    }
168}
169
170/// `MRTR-006`: each `inputRequests` value is one of the three request objects.
171pub(in crate::checks) fn input_request_methods(context: &TraceContext<'_>, sink: &mut FindingSink) {
172    for round in rounds(context) {
173        let Some(requests) = round.requests else {
174            continue;
175        };
176        for (key, request) in requests {
177            sink.examined();
178            match request.get("method").and_then(Value::as_str) {
179                Some(method) if INPUT_REQUEST_METHODS.contains(&method) => {}
180                Some(method) => sink.push(
181                    Some(round.seq),
182                    format!(
183                        "`inputRequests[{key}]` asks for `{method}`, which is not one of \
184                         ElicitRequest, CreateMessageRequest or ListRootsRequest"
185                    ),
186                ),
187                None => sink.push(
188                    Some(round.seq),
189                    format!("`inputRequests[{key}]` is not a request object with a `method`"),
190                ),
191            }
192        }
193    }
194}
195
196/// `MRTR-011`: an `InputRequiredResult` carries `inputRequests`, `requestState`, or both.
197///
198/// A result with neither asks for nothing and remembers nothing, so the round it
199/// opens can never be completed — which is why the clause makes it a MUST rather
200/// than leaving both fields optional independently.
201pub(in crate::checks) fn input_required_has_content(
202    context: &TraceContext<'_>,
203    sink: &mut FindingSink,
204) {
205    for round in rounds(context) {
206        sink.examined();
207        if round.requests.is_none() && round.state.is_none() {
208            sink.push(
209                Some(round.seq),
210                "`input_required` carries neither `inputRequests` nor `requestState`, so the \
211                 round it opens cannot be completed"
212                    .to_owned(),
213            );
214        }
215    }
216}
217
218/// `MRTR-015`: a retry carries responses for everything the round asked for.
219pub(in crate::checks) fn retry_carries_input_responses(
220    context: &TraceContext<'_>,
221    sink: &mut FindingSink,
222) {
223    for (retry, round) in retries_with_rounds(context) {
224        let Some(round) = round else {
225            continue;
226        };
227        // The subject is a retry of a round that actually asked for something:
228        // where nothing was asked, there is nothing a retry could omit.
229        if round.requests.is_none_or(Map::is_empty) {
230            continue;
231        }
232        sink.examined();
233        for key in missing_keys(&round, &retry) {
234            sink.push(
235                Some(retry.seq),
236                format!(
237                    "the retry carries no `inputResponses[{key}]` for the input the \
238                     `input_required` at seq {} asked for",
239                    round.seq
240                ),
241            );
242        }
243    }
244}
245
246/// The `inputRequests` keys a retry left unanswered.
247fn missing_keys(round: &Round<'_>, retry: &Retry<'_>) -> Vec<String> {
248    let Some(requests) = round.requests else {
249        return Vec::new();
250    };
251    requests
252        .keys()
253        .filter(|key| {
254            !retry
255                .responses
256                .is_some_and(|responses| responses.contains_key(*key))
257        })
258        .cloned()
259        .collect()
260}
261
262/// `MRTR-016`, `MRTR-003` and `MRTR-017`: the retry echoes `requestState` exactly.
263///
264/// The three clauses share this check because they state one rule from two
265/// sides: the client must echo the exact value, and must not modify it. A
266/// changed value is the only wire-visible form of "modified" — inspecting and
267/// parsing leave no trace — so a finding here is a true finding for all three.
268pub(in crate::checks) fn request_state_echoed(context: &TraceContext<'_>, sink: &mut FindingSink) {
269    for (retry, round) in retries_with_rounds(context) {
270        let Some(round) = round else {
271            continue;
272        };
273        let Some(issued) = round.state else { continue };
274        sink.examined();
275        match retry.state {
276            Some(echoed) if echoed == issued => {}
277            Some(echoed) => sink.push(
278                Some(retry.seq),
279                format!(
280                    "the retry echoes `requestState` {echoed} instead of the {issued} the \
281                     `input_required` at seq {} issued",
282                    round.seq
283                ),
284            ),
285            None => sink.push(
286                Some(retry.seq),
287                format!(
288                    "the retry omits the `requestState` the `input_required` at seq {} \
289                     issued, which it must echo back exactly",
290                    round.seq
291                ),
292            ),
293        }
294    }
295}
296
297/// `MRTR-018`: no `requestState` in a retry the server did not give one for.
298pub(in crate::checks) fn no_unsolicited_request_state(
299    context: &TraceContext<'_>,
300    sink: &mut FindingSink,
301) {
302    for (retry, round) in retries_with_rounds(context) {
303        if retry.state.is_none() {
304            continue;
305        }
306        sink.examined();
307        let issued = round.and_then(|round| round.state);
308        if issued.is_none() {
309            sink.push(
310                Some(retry.seq),
311                "the request carries a `requestState` that no `input_required` before it \
312                 issued"
313                    .to_owned(),
314            );
315        }
316    }
317}
318
319/// `MRTR-019`: the retry is a new request, with a new id.
320pub(in crate::checks) fn retry_id_differs(context: &TraceContext<'_>, sink: &mut FindingSink) {
321    for (retry, round) in retries_with_rounds(context) {
322        let Some(round) = round else {
323            continue;
324        };
325        sink.examined();
326        let (origin_seq, origin_id, _) = round.origin;
327        if retry.id == origin_id {
328            sink.push(
329                Some(retry.seq),
330                format!(
331                    "the retry reuses id {origin_id} from the request at seq {origin_seq}; \
332                     the two are independent requests and must not share one"
333                ),
334            );
335        }
336    }
337}
338
339/// `MRTR-020`: a round's state is used for its own retry and nothing else.
340///
341/// Judged by method: a `requestState` presented on a request of a different
342/// method than the one that drew it is being used for some other request, which
343/// is what the clause forbids. Two retries of the *same* method are not reported
344/// — the specification explicitly allows a server to open a further round on a
345/// repeated attempt (`#server-requirements-basic-workflow`, item 8).
346pub(in crate::checks) fn request_state_scoped_to_retry(
347    context: &TraceContext<'_>,
348    sink: &mut FindingSink,
349) {
350    // Every state a round issued, and the method of the request that drew it.
351    let issued: BTreeMap<String, &str> = rounds(context)
352        .iter()
353        .filter_map(|round| round.state.map(|state| (state.to_string(), round.origin.2)))
354        .collect();
355    for retry in retries(context) {
356        let Some(state) = retry.state else { continue };
357        let Some(&origin_method) = issued.get(&state.to_string()) else {
358            continue;
359        };
360        sink.examined();
361        if retry.method != origin_method {
362            sink.push(
363                Some(retry.seq),
364                format!(
365                    "`{}` carries the `requestState` issued for a `{origin_method}` request; \
366                     it affects only that request's retry",
367                    retry.method
368                ),
369            );
370        }
371    }
372}
373
374/// `MRTR-024`: a shortfall draws another `input_required`, not an error.
375///
376/// Fires only when the trace shows all three parts the clause names: a round
377/// that asked for input, a retry that did not supply all of it, and an *error*
378/// answering that retry. The clause's remaining condition — that the missing
379/// information was necessary — is the server's own judgement and is not
380/// observable; a server that could proceed without it would have completed the
381/// request rather than failing it, which is why the error is treated as
382/// evidence that it could not.
383pub(in crate::checks) fn missing_input_reasked(context: &TraceContext<'_>, sink: &mut FindingSink) {
384    let paired: BTreeMap<u64, (Retry<'_>, Option<Round<'_>>)> = retries_with_rounds(context)
385        .into_iter()
386        .map(|(retry, round)| (retry.seq, (retry, round)))
387        .collect();
388    for exchange in context.exchanges() {
389        let Some((retry, Some(round))) = paired.get(&exchange.request.seq).copied() else {
390            continue;
391        };
392        let missing = missing_keys(&round, &retry);
393        if missing.is_empty() {
394            continue; // Nothing was omitted, so no shortfall to answer.
395        }
396        // The subject is an *answered* retry that fell short: the clause is
397        // about which of the two answers the server chose.
398        sink.examined();
399        if exchange.result.is_some() {
400            continue;
401        }
402        sink.push(
403            Some(exchange.response.seq),
404            format!(
405                "the retry omitted {} that the `input_required` at seq {} asked for, and the \
406                 server answered with an error rather than asking again",
407                missing
408                    .iter()
409                    .map(|key| format!("`{key}`"))
410                    .collect::<Vec<_>>()
411                    .join(", "),
412                round.seq
413            ),
414        );
415    }
416}