Skip to main content

mcp_trace_validator/checks/
utilities.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the `2025-11-25` server-utilities requirements: logging (`LOG-*`),
5//! completion (`COMP-*`), and pagination (`PAGE-*`).
6
7use std::collections::BTreeSet;
8
9use serde_json::Value;
10
11use super::FindingSink;
12use super::support::{Declaration, server_capability};
13use crate::context::TraceContext;
14use mcp_conformance_core::message::MessageKind;
15use mcp_conformance_core::trace::Direction;
16
17/// `LOG-001`: "Servers that emit log message notifications MUST declare the `logging`
18/// capability:" — emission is directly observable.
19pub(super) fn logging_capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
20    // The subject is an emitted log notification, declaration or not; a session
21    // in which the server never logged leaves this clause untested.
22    let declared = match server_capability(context, &["logging"]) {
23        Declaration::Declared => true,
24        Declaration::Withheld => false,
25        // Nothing in this trace could have declared anything, so it shows
26        // neither compliance nor violation: abstain before counting a subject.
27        Declaration::Unknowable => return,
28    };
29    for (event, kind, _) in context.messages() {
30        if event.direction != Direction::ServerToClient {
31            continue;
32        }
33        if matches!(kind, MessageKind::Notification { method } if *method == "notifications/message")
34        {
35            sink.examined();
36            if !declared {
37                sink.push(
38                    Some(event.seq),
39                    "server emitted a log message notification without declaring the logging capability"
40                        .to_owned(),
41                );
42            }
43        }
44    }
45}
46
47/// `COMP-001`: "Servers that support completions MUST declare the `completions`
48/// capability:" — successfully answering `completion/complete` is the observable form
49/// of support.
50pub(super) fn completion_capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
51    // The subject is an answered completion, declaration or not; a session that
52    // never asked for one leaves this clause untested.
53    let declared = match server_capability(context, &["completions"]) {
54        Declaration::Declared => true,
55        Declaration::Withheld => false,
56        // Nothing in this trace could have declared anything, so it shows
57        // neither compliance nor violation: abstain before counting a subject.
58        Declaration::Unknowable => return,
59    };
60    for exchange in context.exchanges_for("completion/complete") {
61        if exchange.result.is_some() {
62            sink.examined();
63            if !declared {
64                sink.push(
65                    Some(exchange.response.seq),
66                    "server answered completion/complete without declaring the completions capability"
67                        .to_owned(),
68                );
69            }
70        }
71    }
72}
73
74/// The list-style methods whose results may carry a `nextCursor`.
75const PAGINATED_METHODS: &[&str] = &[
76    "resources/list",
77    "resources/templates/list",
78    "prompts/list",
79    "tools/list",
80];
81
82/// `PAGE-002`: clients must treat cursors as opaque tokens. The trace-observable
83/// violation is *provenance*: a `cursor` parameter the server never issued as a
84/// `nextCursor` for that method earlier in this session is fabricated, modified, or
85/// carried over from another session — all three of which the clause forbids.
86pub(super) fn cursor_opacity(context: &TraceContext<'_>, sink: &mut FindingSink) {
87    // nextCursor issuances, keyed by the seq of the result that carried them.
88    let issuances: std::collections::BTreeMap<u64, (&str, &str)> = context
89        .exchanges()
90        .filter(|exchange| PAGINATED_METHODS.contains(&exchange.method))
91        .filter_map(|exchange| {
92            let cursor = exchange.result?.get("nextCursor")?.as_str()?;
93            Some((exchange.response.seq, (exchange.method, cursor)))
94        })
95        .collect();
96
97    let mut issued: Vec<(&str, &str)> = Vec::new();
98    for (event, kind, _) in context.messages() {
99        if let (Direction::ClientToServer, MessageKind::Request { method, .. }) =
100            (event.direction, kind)
101            && PAGINATED_METHODS.contains(method)
102        {
103            check_cursor_provenance(event, method, &issued, sink);
104        }
105        // Issuances take effect after their event, in trace order.
106        if let Some(issuance) = issuances.get(&event.seq) {
107            issued.push(*issuance);
108        }
109    }
110}
111
112fn check_cursor_provenance(
113    event: &mcp_conformance_core::trace::TraceEvent,
114    method: &str,
115    issued: &[(&str, &str)],
116    sink: &mut FindingSink,
117) {
118    let cursor = event
119        .message_payload()
120        .and_then(|payload| payload.get("params"))
121        .and_then(|params| params.get("cursor"));
122    let Some(cursor) = cursor else { return };
123    // The subject is a *continuation* request: a first page carries no cursor
124    // and so puts no opacity claim to the test.
125    sink.examined();
126    let Some(cursor) = cursor.as_str() else {
127        sink.push(
128            Some(event.seq),
129            format!("{method} cursor is {cursor}, expected an opaque string token"),
130        );
131        return;
132    };
133    if !issued.contains(&(method, cursor)) {
134        sink.push(
135            Some(event.seq),
136            format!(
137                "{method} cursor {cursor:?} was never issued as a nextCursor for that method in this session"
138            ),
139        );
140    }
141}
142
143/// JSON-RPC `Invalid params`, which an invalid cursor must draw.
144const INVALID_PARAMS: i64 = -32602;
145
146/// `PAGE-003` / `PAGE-011`: an invalid cursor draws `-32602`.
147///
148/// "Invalid" is witnessed the only way a recording can witness it: the client
149/// presented a cursor that this session never issued as a `nextCursor` for that
150/// method. A cursor that *was* issued may still have expired, and a trace cannot
151/// tell — so those are not judged, and the check abstains rather than guessing.
152///
153/// That narrowing is the whole point, and it is why `2025-11-25` carried an
154/// exclusion here until 2026-08-21: *"whether a cursor is invalid is
155/// server-internal knowledge; a trace cannot distinguish a server accepting a
156/// stale-but-valid cursor from one silently tolerating an invalid one."* True of
157/// the general case, and it reads as a verdict on the clause. The narrow case
158/// the sentence itself excludes — a cursor with no issuance anywhere in the
159/// session — is decidable from the recording alone, and the clause is judged on
160/// exactly that. The exclusion was written before the witness was found and
161/// nothing re-read it; `corpus/violations/page-002-cursor-never-issued.jsonl`
162/// had been sitting in the corpus, fabricated cursor answered with a result,
163/// the whole time.
164///
165/// Where this fires, the opacity clause usually fires too, and that is not
166/// double reporting: the client fabricated the cursor (its defect) and the
167/// server then honoured it instead of rejecting it (the server's). Only the
168/// second is this clause's.
169pub(super) fn invalid_cursor_rejected(context: &TraceContext<'_>, sink: &mut FindingSink) {
170    let mut issued: BTreeSet<(&str, &str)> = BTreeSet::new();
171    // Issuances take effect after the result that carried them, so a cursor is
172    // only "known" to requests that follow it — walking exchanges in order keeps
173    // a server from being excused by a cursor it had not yet handed out.
174    let mut exchanges: Vec<_> = context.exchanges().collect();
175    exchanges.sort_by_key(|exchange| exchange.request.seq);
176    for exchange in exchanges {
177        if !PAGINATED_METHODS.contains(&exchange.method) {
178            continue;
179        }
180        let presented = exchange
181            .params
182            .and_then(|params| params.get("cursor"))
183            .and_then(Value::as_str);
184        if let Some(cursor) = presented
185            && !issued.contains(&(exchange.method, cursor))
186        {
187            // The subject is a request presenting a cursor this session never
188            // issued: no such request, and the clause is untested here.
189            sink.examined();
190            let code = exchange
191                .response
192                .message_payload()
193                .and_then(|payload| payload.get("error"))
194                .and_then(|error| error.get("code"))
195                .and_then(Value::as_i64);
196            if code != Some(INVALID_PARAMS) {
197                sink.push(
198                    Some(exchange.response.seq),
199                    format!(
200                        "`{}` presented the cursor {cursor:?}, which this session never issued, \
201                         and the server answered with {} rather than {INVALID_PARAMS}",
202                        exchange.method,
203                        code.map_or_else(|| "a result".to_owned(), |code| format!("error {code}"))
204                    ),
205                );
206            }
207        }
208        if let Some(next) = exchange
209            .result
210            .and_then(|result| result.get("nextCursor"))
211            .and_then(Value::as_str)
212        {
213            issued.insert((exchange.method, next));
214        }
215    }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used)]
220mod tests {
221    use crate::checks;
222    use crate::context::TraceContext;
223    use crate::reader::{Limits, parse_trace};
224
225    fn findings_for(check: &str, trace: &str) -> Vec<String> {
226        let events = parse_trace(trace, &Limits::default()).unwrap();
227        let context = TraceContext::new(&events);
228        checks::find(check)
229            .unwrap()
230            .run(&context)
231            .findings
232            .into_iter()
233            .map(|finding| finding.detail)
234            .collect()
235    }
236
237    const HANDSHAKE: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}
238{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"0"}}}}
239{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
240
241    // --- PAGE-003 / PAGE-011: the invalid-cursor rejection ---------------
242    //
243    // The check treats "never issued in this session" as the witness for
244    // "invalid", so the cases that keep it honest are a cursor issued
245    // *earlier* (valid, not reported) and one issued for a different method
246    // (not this method's cursor).
247
248    const INVALID_CURSOR: &str = "pagination.invalid-cursor-rejected";
249
250    /// A list request for `method`, presenting `cursor` when given.
251    fn list(seq: u64, id: u64, method: &str, cursor: Option<&str>) -> String {
252        let params = cursor.map_or_else(String::new, |cursor| {
253            format!(r#","params":{{"cursor":"{cursor}"}}"#)
254        });
255        format!(
256            r#"{{"seq":{seq},"direction":"client-to-server","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":{id},"method":"{method}"{params}}}}}"#
257        )
258    }
259
260    /// A page result, issuing `next` when given.
261    fn page(seq: u64, id: u64, next: Option<&str>) -> String {
262        let cursor = next.map_or_else(String::new, |next| format!(r#","nextCursor":"{next}""#));
263        format!(
264            r#"{{"seq":{seq},"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":{id},"result":{{"tools":[]{cursor}}}}}}}"#
265        )
266    }
267
268    /// An error answer carrying `code`.
269    fn error(seq: u64, id: u64, code: i64) -> String {
270        format!(
271            r#"{{"seq":{seq},"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":{id},"error":{{"code":{code},"message":"no"}}}}}}"#
272        )
273    }
274
275    fn session(body: &[String]) -> String {
276        format!("{HANDSHAKE}\n{}", body.join("\n"))
277    }
278
279    #[test]
280    fn an_unissued_cursor_answered_with_a_result_is_reported() {
281        let trace = session(&[list(3, 2, "tools/list", Some("made-up")), page(4, 2, None)]);
282        let findings = findings_for(INVALID_CURSOR, &trace);
283        assert_eq!(findings.len(), 1, "{findings:?}");
284        assert!(findings[0].contains("made-up"), "{findings:?}");
285    }
286
287    #[test]
288    fn rejecting_it_with_invalid_params_conforms() {
289        let trace = session(&[
290            list(3, 2, "tools/list", Some("made-up")),
291            error(4, 2, -32602),
292        ]);
293        assert!(findings_for(INVALID_CURSOR, &trace).is_empty());
294    }
295
296    #[test]
297    fn some_other_error_is_not_the_required_rejection() {
298        let trace = session(&[
299            list(3, 2, "tools/list", Some("made-up")),
300            error(4, 2, -32603),
301        ]);
302        let findings = findings_for(INVALID_CURSOR, &trace);
303        assert_eq!(findings.len(), 1, "{findings:?}");
304        assert!(findings[0].contains("-32603"), "{findings:?}");
305    }
306
307    #[test]
308    fn a_cursor_the_server_issued_is_valid() {
309        let trace = session(&[
310            list(3, 2, "tools/list", None),
311            page(4, 2, Some("page2")),
312            list(5, 3, "tools/list", Some("page2")),
313            page(6, 3, None),
314        ]);
315        assert!(findings_for(INVALID_CURSOR, &trace).is_empty());
316    }
317
318    #[test]
319    fn a_cursor_issued_for_another_method_is_not_this_ones() {
320        let trace = session(&[
321            list(3, 2, "prompts/list", None),
322            page(4, 2, Some("page2")),
323            list(5, 3, "tools/list", Some("page2")),
324            page(6, 3, None),
325        ]);
326        assert_eq!(findings_for(INVALID_CURSOR, &trace).len(), 1);
327    }
328
329    #[test]
330    fn a_cursor_used_before_it_was_issued_is_still_unissued() {
331        // Order matters: the server cannot be excused by a cursor it handed
332        // out afterwards.
333        let trace = session(&[
334            list(3, 2, "tools/list", Some("page2")),
335            page(4, 2, Some("page2")),
336        ]);
337        assert_eq!(findings_for(INVALID_CURSOR, &trace).len(), 1);
338    }
339
340    #[test]
341    fn a_list_request_with_no_cursor_is_not_judged() {
342        let trace = session(&[list(3, 2, "tools/list", None), page(4, 2, None)]);
343        assert!(findings_for(INVALID_CURSOR, &trace).is_empty());
344    }
345
346    #[test]
347    fn issued_cursors_may_be_replayed_for_the_same_method() {
348        let trace = format!(
349            "{HANDSHAKE}\n{}\n{}\n{}\n{}",
350            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
351            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[],"nextCursor":"abc"}}}"#,
352            r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"abc"}}}"#,
353            r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"tools":[]}}}"#,
354        );
355        assert!(findings_for("pagination.cursor-opacity", &trace).is_empty());
356    }
357
358    #[test]
359    fn cursors_do_not_transfer_between_methods() {
360        // A cursor issued for tools/list replayed against prompts/list is misuse.
361        let trace = format!(
362            "{HANDSHAKE}\n{}\n{}\n{}",
363            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
364            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[],"nextCursor":"abc"}}}"#,
365            r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"prompts/list","params":{"cursor":"abc"}}}"#,
366        );
367        let findings = findings_for("pagination.cursor-opacity", &trace);
368        assert_eq!(findings.len(), 1, "{findings:?}");
369        assert!(findings[0].contains("prompts/list"), "{findings:?}");
370    }
371
372    #[test]
373    fn non_string_cursors_are_flagged_as_non_opaque() {
374        let trace = format!(
375            "{HANDSHAKE}\n{}",
376            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"cursor":7}}}"#,
377        );
378        let findings = findings_for("pagination.cursor-opacity", &trace);
379        assert_eq!(findings.len(), 1, "{findings:?}");
380        assert!(
381            findings[0].contains("expected an opaque string"),
382            "{findings:?}"
383        );
384    }
385}