1use std::collections::HashMap;
14
15use mcp_conformance_core::canonical::to_canonical_string;
16use mcp_conformance_core::message::{MessageKind, is_notification_method};
17use mcp_conformance_core::trace::Direction;
18use serde_json::Value;
19
20use super::FindingSink;
21use crate::context::TraceContext;
22
23mod correlation;
24mod meta;
25
26pub(super) use correlation::{error_id_matches, result_id_matches};
27pub(super) use meta::meta_key_format;
28#[cfg(feature = "draft-2026-07-28")]
32pub(super) use meta::validate_meta_key;
33
34fn type_name(value: &Value) -> &'static str {
36 match value {
37 Value::Null => "null",
38 Value::Bool(_) => "a boolean",
39 Value::Number(number) => {
40 if number.is_i64() || number.is_u64() {
41 "an integer"
42 } else {
43 "a non-integer number"
44 }
45 }
46 Value::String(_) => "a string",
47 Value::Array(_) => "an array",
48 Value::Object(_) => "an object",
49 }
50}
51
52fn id_is_string_or_integer(id: &Value) -> bool {
53 match id {
54 Value::String(_) => true,
55 Value::Number(number) => number.is_i64() || number.is_u64(),
56 _ => false,
57 }
58}
59
60pub(super) fn request_id_type(context: &TraceContext<'_>, sink: &mut FindingSink) {
62 for (event, kind, _) in context.messages() {
63 let MessageKind::Request { method, id } = kind else {
64 continue;
65 };
66 sink.examined();
67 if !id_is_string_or_integer(id) {
68 sink.push(
69 Some(event.seq),
70 format!(
71 "request {method:?} carries {} as its id; the ID must be a string or an integer",
72 type_name(id)
73 ),
74 );
75 }
76 }
77}
78
79pub(super) fn request_id_not_null(context: &TraceContext<'_>, sink: &mut FindingSink) {
81 for (event, kind, _) in context.messages() {
82 let MessageKind::Request { method, id } = kind else {
83 continue;
84 };
85 sink.examined();
86 if id.is_null() {
87 sink.push(
88 Some(event.seq),
89 format!("request {method:?} carries a null id, which MCP forbids"),
90 );
91 }
92 }
93}
94
95pub(super) fn request_id_unique(context: &TraceContext<'_>, sink: &mut FindingSink) {
98 let mut first_use: HashMap<(Direction, String), u64> = HashMap::new();
99 for (event, kind, _) in context.messages() {
100 if let MessageKind::Request { method, id } = kind {
101 if id.is_null() {
102 continue; }
104 sink.examined();
105 let key = (event.direction, to_canonical_string(id));
106 match first_use.get(&key) {
107 Some(previous) => sink.push(
108 Some(event.seq),
109 format!(
110 "request {method:?} reuses id {}, already used by the same party at seq {previous}",
111 key.1
112 ),
113 ),
114 None => {
115 first_use.insert(key, event.seq);
116 }
117 }
118 }
119 }
120}
121
122pub(super) fn notification_no_id(context: &TraceContext<'_>, sink: &mut FindingSink) {
127 for (event, kind, _) in context.messages() {
128 let MessageKind::Request { method, .. } = kind else {
129 continue;
130 };
131 sink.examined();
132 if is_notification_method(method) {
133 sink.push(
134 Some(event.seq),
135 format!(
136 "{method:?} is a notification method but the message carries an id; notifications must not include one"
137 ),
138 );
139 }
140 }
141}
142
143pub(super) fn error_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
146 for (event, kind, _) in context.messages() {
147 if let MessageKind::Error { error, .. } = kind {
148 sink.examined();
149 let Some(object) = error.as_object() else {
150 sink.push(
151 Some(event.seq),
152 format!("error member is {}, expected an object", type_name(error)),
153 );
154 continue;
155 };
156 if !object.contains_key("code") {
157 sink.push(
158 Some(event.seq),
159 "error object lacks a code member".to_owned(),
160 );
161 }
162 match object.get("message") {
163 None => sink.push(
164 Some(event.seq),
165 "error object lacks a message member".to_owned(),
166 ),
167 Some(message) if !message.is_string() => sink.push(
168 Some(event.seq),
169 format!(
170 "error message member is {}, expected a string",
171 type_name(message)
172 ),
173 ),
174 Some(_) => {}
175 }
176 }
177 }
178}
179
180pub(super) fn error_code_integer(context: &TraceContext<'_>, sink: &mut FindingSink) {
182 for (event, kind, _) in context.messages() {
183 let MessageKind::Error { error, .. } = kind else {
184 continue;
185 };
186 let Some(code) = error.get("code") else {
189 continue;
190 };
191 sink.examined();
192 if !code.is_i64() && !code.is_u64() {
193 sink.push(
194 Some(event.seq),
195 format!("error code is {}, expected an integer", type_name(code)),
196 );
197 }
198 }
199}
200
201pub(super) fn result_field(context: &TraceContext<'_>, sink: &mut FindingSink) {
213 for (event, kind, _) in context.messages() {
214 if !matches!(
217 kind,
218 MessageKind::Invalid { .. } | MessageKind::Result { .. }
219 ) {
220 continue;
221 }
222 let Some(object) = event.message_payload().and_then(Value::as_object) else {
223 continue;
224 };
225 if !object.contains_key("id") || object.contains_key("method") {
226 continue;
227 }
228 sink.examined();
229 if !object.contains_key("result") {
237 sink.push(
238 Some(event.seq),
239 "response-shaped message (id present, no method) carries no result field"
240 .to_owned(),
241 );
242 }
243 }
244}
245
246pub(super) fn jsonrpc_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
250 for (event, kind, _) in context.messages() {
251 sink.examined();
252 if let MessageKind::Invalid { reason } = kind {
253 sink.push(
254 Some(event.seq),
255 format!("message is not a JSON-RPC request, notification, or response: {reason}"),
256 );
257 continue;
258 }
259 let version = event
260 .message_payload()
261 .and_then(|payload| payload.get("jsonrpc"));
262 match version {
263 Some(Value::String(version)) if version == "2.0" => {}
264 Some(other) => sink.push(
265 Some(event.seq),
266 format!("jsonrpc member is {other}, expected the string \"2.0\""),
267 ),
268 None => sink.push(
269 Some(event.seq),
270 "message lacks the jsonrpc member; JSON-RPC 2.0 requires \"jsonrpc\": \"2.0\""
271 .to_owned(),
272 ),
273 }
274 }
275}
276
277#[cfg(test)]
278#[allow(clippy::unwrap_used)]
279mod tests {
280 use crate::checks;
281 use crate::context::TraceContext;
282 use crate::reader::{Limits, parse_trace};
283 use crate::report::Finding;
284 use mcp_conformance_core::trace::TraceEvent;
285
286 fn run_check(check_id: &str, trace: &str) -> Vec<Finding> {
287 let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
288 let context = TraceContext::new(&events);
289 checks::find(check_id).unwrap().run(&context).findings
290 }
291
292 const INIT: &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"}}}}"#;
293
294 #[test]
295 fn a_response_shaped_message_is_judged_on_its_result_member_alone() {
296 let neither = r#"{"seq":0,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1}}"#;
300 let findings = run_check("base.result-field", neither);
301 assert_eq!(findings.len(), 1, "{findings:?}");
302 assert!(
303 findings[0].detail.contains("no result field"),
304 "{findings:?}"
305 );
306
307 let both = r#"{"seq":0,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-1,"message":"x"}}}"#;
310 assert!(run_check("base.result-field", both).is_empty());
311
312 let clean = r#"{"seq":0,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{}}}"#;
314 assert!(run_check("base.result-field", clean).is_empty());
315 }
316
317 #[test]
318 fn result_response_with_null_id_gets_the_null_detail() {
319 let trace = format!(
321 "{INIT}\n{}",
322 r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":null,"result":{}}}"#
323 );
324 let findings = run_check("base.result-id-matches", &trace);
325 assert_eq!(findings.len(), 1);
326 assert!(
327 findings[0].detail.contains("null id"),
328 "{}",
329 findings[0].detail
330 );
331 }
332
333 #[test]
334 fn error_message_member_type_is_named_precisely() {
335 let trace = format!(
338 "{INIT}\n{}",
339 r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":-5}}}"#
340 );
341 let findings = run_check("base.error-shape", &trace);
342 assert_eq!(findings.len(), 1);
343 assert!(
344 findings[0]
345 .detail
346 .contains("is an integer, expected a string"),
347 "{}",
348 findings[0].detail
349 );
350 }
351
352 #[test]
353 fn u64_only_request_ids_are_valid_integers() {
354 let trace = format!(
356 "{INIT}\n{}",
357 r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":18446744073709551615,"method":"tools/list"}}"#
358 );
359 assert!(run_check("base.request-id-type", &trace).is_empty());
360 }
361
362 const REQUEST: &str = r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#;
367 const RESULT_2: &str = r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{}}}"#;
368 const ERROR_2: &str = r#"{"seq":2,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"x"}}}"#;
369
370 #[test]
371 fn error_then_result_flags_the_second_answer_as_a_result() {
372 let trace = format!("{INIT}\n{REQUEST}\n{ERROR_2}\n{RESULT_2}");
375 let results = run_check("base.result-id-matches", &trace);
376 assert_eq!(results.len(), 1, "{results:?}");
377 assert_eq!(results[0].seq, Some(3));
378 assert!(
379 results[0].detail.contains("already answered"),
380 "{results:?}"
381 );
382 assert!(
383 run_check("base.error-id-matches", &trace).is_empty(),
384 "the error was the legitimate first answer"
385 );
386 }
387
388 #[test]
389 fn result_then_error_flags_the_second_answer_as_an_error() {
390 let result_seq2 = RESULT_2.replace("\"seq\":3", "\"seq\":2");
393 let error_seq3 = ERROR_2.replace("\"seq\":2", "\"seq\":3");
394 let trace = format!("{INIT}\n{REQUEST}\n{result_seq2}\n{error_seq3}");
395 let errors = run_check("base.error-id-matches", &trace);
396 assert_eq!(errors.len(), 1, "{errors:?}");
397 assert_eq!(errors[0].seq, Some(3));
398 assert!(errors[0].detail.contains("already answered"), "{errors:?}");
399 assert!(
400 run_check("base.result-id-matches", &trace).is_empty(),
401 "the result was the legitimate first answer"
402 );
403 }
404
405 #[test]
406 fn single_flavor_answer_is_not_flagged_by_the_other_pass() {
407 let trace = format!(
410 "{INIT}\n{REQUEST}\n{}",
411 RESULT_2.replace("\"seq\":3", "\"seq\":2")
412 );
413 assert!(run_check("base.result-id-matches", &trace).is_empty());
414 assert!(run_check("base.error-id-matches", &trace).is_empty());
415 }
416}