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 super::FindingSink;
8use super::support::server_capability;
9use crate::context::TraceContext;
10use mcp_conformance_core::message::MessageKind;
11use mcp_conformance_core::trace::Direction;
12
13/// `LOG-001`: "Servers that emit log message notifications MUST declare the `logging`
14/// capability:" — emission is directly observable.
15pub(super) fn logging_capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
16    if server_capability(context, &["logging"]) != Some(false) {
17        return;
18    }
19    for (event, kind, _) in context.messages() {
20        if event.direction != Direction::ServerToClient {
21            continue;
22        }
23        if matches!(kind, MessageKind::Notification { method } if *method == "notifications/message")
24        {
25            sink.push(
26                Some(event.seq),
27                "server emitted a log message notification without declaring the logging capability"
28                    .to_owned(),
29            );
30        }
31    }
32}
33
34/// `COMP-001`: "Servers that support completions MUST declare the `completions`
35/// capability:" — successfully answering `completion/complete` is the observable form
36/// of support.
37pub(super) fn completion_capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
38    if server_capability(context, &["completions"]) != Some(false) {
39        return;
40    }
41    for exchange in context.exchanges_for("completion/complete") {
42        if exchange.result.is_some() {
43            sink.push(
44                Some(exchange.response.seq),
45                "server answered completion/complete without declaring the completions capability"
46                    .to_owned(),
47            );
48        }
49    }
50}
51
52/// The list-style methods whose results may carry a `nextCursor`.
53const PAGINATED_METHODS: &[&str] = &[
54    "resources/list",
55    "resources/templates/list",
56    "prompts/list",
57    "tools/list",
58];
59
60/// `PAGE-002`: clients must treat cursors as opaque tokens. The trace-observable
61/// violation is *provenance*: a `cursor` parameter the server never issued as a
62/// `nextCursor` for that method earlier in this session is fabricated, modified, or
63/// carried over from another session — all three of which the clause forbids.
64pub(super) fn cursor_opacity(context: &TraceContext<'_>, sink: &mut FindingSink) {
65    // nextCursor issuances, keyed by the seq of the result that carried them.
66    let issuances: std::collections::BTreeMap<u64, (&str, &str)> = context
67        .exchanges()
68        .filter(|exchange| PAGINATED_METHODS.contains(&exchange.method))
69        .filter_map(|exchange| {
70            let cursor = exchange.result?.get("nextCursor")?.as_str()?;
71            Some((exchange.response.seq, (exchange.method, cursor)))
72        })
73        .collect();
74
75    let mut issued: Vec<(&str, &str)> = Vec::new();
76    for (event, kind, _) in context.messages() {
77        if let (Direction::ClientToServer, MessageKind::Request { method, .. }) =
78            (event.direction, kind)
79            && PAGINATED_METHODS.contains(method)
80        {
81            check_cursor_provenance(event, method, &issued, sink);
82        }
83        // Issuances take effect after their event, in trace order.
84        if let Some(issuance) = issuances.get(&event.seq) {
85            issued.push(*issuance);
86        }
87    }
88}
89
90fn check_cursor_provenance(
91    event: &mcp_conformance_core::trace::TraceEvent,
92    method: &str,
93    issued: &[(&str, &str)],
94    sink: &mut FindingSink,
95) {
96    let cursor = event
97        .message_payload()
98        .and_then(|payload| payload.get("params"))
99        .and_then(|params| params.get("cursor"));
100    let Some(cursor) = cursor else { return };
101    let Some(cursor) = cursor.as_str() else {
102        sink.push(
103            Some(event.seq),
104            format!("{method} cursor is {cursor}, expected an opaque string token"),
105        );
106        return;
107    };
108    if !issued.contains(&(method, cursor)) {
109        sink.push(
110            Some(event.seq),
111            format!(
112                "{method} cursor {cursor:?} was never issued as a nextCursor for that method in this session"
113            ),
114        );
115    }
116}
117
118#[cfg(test)]
119#[allow(clippy::unwrap_used)]
120mod tests {
121    use crate::checks;
122    use crate::context::TraceContext;
123    use crate::reader::{Limits, parse_trace};
124
125    fn findings_for(check: &str, trace: &str) -> Vec<String> {
126        let events = parse_trace(trace, &Limits::default()).unwrap();
127        let context = TraceContext::new(&events);
128        checks::find(check)
129            .unwrap()
130            .run(&context)
131            .into_iter()
132            .map(|finding| finding.detail)
133            .collect()
134    }
135
136    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"}}}}
137{"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"}}}}
138{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
139
140    #[test]
141    fn issued_cursors_may_be_replayed_for_the_same_method() {
142        let trace = format!(
143            "{HANDSHAKE}\n{}\n{}\n{}\n{}",
144            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
145            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[],"nextCursor":"abc"}}}"#,
146            r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"abc"}}}"#,
147            r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"tools":[]}}}"#,
148        );
149        assert!(findings_for("pagination.cursor-opacity", &trace).is_empty());
150    }
151
152    #[test]
153    fn cursors_do_not_transfer_between_methods() {
154        // A cursor issued for tools/list replayed against prompts/list is misuse.
155        let trace = format!(
156            "{HANDSHAKE}\n{}\n{}\n{}",
157            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
158            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[],"nextCursor":"abc"}}}"#,
159            r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"prompts/list","params":{"cursor":"abc"}}}"#,
160        );
161        let findings = findings_for("pagination.cursor-opacity", &trace);
162        assert_eq!(findings.len(), 1, "{findings:?}");
163        assert!(findings[0].contains("prompts/list"), "{findings:?}");
164    }
165
166    #[test]
167    fn non_string_cursors_are_flagged_as_non_opaque() {
168        let trace = format!(
169            "{HANDSHAKE}\n{}",
170            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"cursor":7}}}"#,
171        );
172        let findings = findings_for("pagination.cursor-opacity", &trace);
173        assert_eq!(findings.len(), 1, "{findings:?}");
174        assert!(
175            findings[0].contains("expected an opaque string"),
176            "{findings:?}"
177        );
178    }
179}