mcp_trace_validator/checks/
utilities.rs1use super::FindingSink;
8use super::support::server_capability;
9use crate::context::TraceContext;
10use mcp_conformance_core::message::MessageKind;
11use mcp_conformance_core::trace::Direction;
12
13pub(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
34pub(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
52const PAGINATED_METHODS: &[&str] = &[
54 "resources/list",
55 "resources/templates/list",
56 "prompts/list",
57 "tools/list",
58];
59
60pub(super) fn cursor_opacity(context: &TraceContext<'_>, sink: &mut FindingSink) {
65 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 {
80 if PAGINATED_METHODS.contains(method) {
81 check_cursor_provenance(event, method, &issued, sink);
82 }
83 }
84 if let Some(issuance) = issuances.get(&event.seq) {
86 issued.push(*issuance);
87 }
88 }
89}
90
91fn check_cursor_provenance(
92 event: &mcp_conformance_core::trace::TraceEvent,
93 method: &str,
94 issued: &[(&str, &str)],
95 sink: &mut FindingSink,
96) {
97 let cursor = event
98 .message_payload()
99 .and_then(|payload| payload.get("params"))
100 .and_then(|params| params.get("cursor"));
101 let Some(cursor) = cursor else { return };
102 let Some(cursor) = cursor.as_str() else {
103 sink.push(
104 Some(event.seq),
105 format!("{method} cursor is {cursor}, expected an opaque string token"),
106 );
107 return;
108 };
109 if !issued.contains(&(method, cursor)) {
110 sink.push(
111 Some(event.seq),
112 format!(
113 "{method} cursor {cursor:?} was never issued as a nextCursor for that method in this session"
114 ),
115 );
116 }
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used)]
121mod tests {
122 use crate::checks;
123 use crate::context::TraceContext;
124 use crate::reader::{Limits, parse_trace};
125
126 fn findings_for(check: &str, trace: &str) -> Vec<String> {
127 let events = parse_trace(trace, &Limits::default()).unwrap();
128 let context = TraceContext::new(&events);
129 checks::find(check)
130 .unwrap()
131 .run(&context)
132 .into_iter()
133 .map(|finding| finding.detail)
134 .collect()
135 }
136
137 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"}}}}
138{"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"}}}}
139{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
140
141 #[test]
142 fn issued_cursors_may_be_replayed_for_the_same_method() {
143 let trace = format!(
144 "{HANDSHAKE}\n{}\n{}\n{}\n{}",
145 r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
146 r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[],"nextCursor":"abc"}}}"#,
147 r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"abc"}}}"#,
148 r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"tools":[]}}}"#,
149 );
150 assert!(findings_for("pagination.cursor-opacity", &trace).is_empty());
151 }
152
153 #[test]
154 fn cursors_do_not_transfer_between_methods() {
155 let trace = format!(
157 "{HANDSHAKE}\n{}\n{}\n{}",
158 r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
159 r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[],"nextCursor":"abc"}}}"#,
160 r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"prompts/list","params":{"cursor":"abc"}}}"#,
161 );
162 let findings = findings_for("pagination.cursor-opacity", &trace);
163 assert_eq!(findings.len(), 1, "{findings:?}");
164 assert!(findings[0].contains("prompts/list"), "{findings:?}");
165 }
166
167 #[test]
168 fn non_string_cursors_are_flagged_as_non_opaque() {
169 let trace = format!(
170 "{HANDSHAKE}\n{}",
171 r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"cursor":7}}}"#,
172 );
173 let findings = findings_for("pagination.cursor-opacity", &trace);
174 assert_eq!(findings.len(), 1, "{findings:?}");
175 assert!(
176 findings[0].contains("expected an opaque string"),
177 "{findings:?}"
178 );
179 }
180}