1use 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
17pub(super) fn logging_capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
20 let declared = match server_capability(context, &["logging"]) {
23 Declaration::Declared => true,
24 Declaration::Withheld => false,
25 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
47pub(super) fn completion_capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
51 let declared = match server_capability(context, &["completions"]) {
54 Declaration::Declared => true,
55 Declaration::Withheld => false,
56 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
74const PAGINATED_METHODS: &[&str] = &[
76 "resources/list",
77 "resources/templates/list",
78 "prompts/list",
79 "tools/list",
80];
81
82pub(super) fn cursor_opacity(context: &TraceContext<'_>, sink: &mut FindingSink) {
87 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 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 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
143const INVALID_PARAMS: i64 = -32602;
145
146pub(super) fn invalid_cursor_rejected(context: &TraceContext<'_>, sink: &mut FindingSink) {
170 let mut issued: BTreeSet<(&str, &str)> = BTreeSet::new();
171 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 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 const INVALID_CURSOR: &str = "pagination.invalid-cursor-rejected";
249
250 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 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 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 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 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}