1use std::collections::HashMap;
10
11use mcp_conformance_core::canonical::to_canonical_string;
12use mcp_conformance_core::message::{MessageKind, is_notification_method};
13use mcp_conformance_core::trace::Direction;
14use serde_json::Value;
15
16use super::FindingSink;
17use crate::context::TraceContext;
18
19mod meta;
20
21pub(super) use meta::meta_key_format;
22
23fn type_name(value: &Value) -> &'static str {
25 match value {
26 Value::Null => "null",
27 Value::Bool(_) => "a boolean",
28 Value::Number(number) => {
29 if number.is_i64() || number.is_u64() {
30 "an integer"
31 } else {
32 "a non-integer number"
33 }
34 }
35 Value::String(_) => "a string",
36 Value::Array(_) => "an array",
37 Value::Object(_) => "an object",
38 }
39}
40
41fn id_is_string_or_integer(id: &Value) -> bool {
42 match id {
43 Value::String(_) => true,
44 Value::Number(number) => number.is_i64() || number.is_u64(),
45 _ => false,
46 }
47}
48
49pub(super) fn request_id_type(context: &TraceContext<'_>, sink: &mut FindingSink) {
51 for (event, kind, _) in context.messages() {
52 if let MessageKind::Request { method, id } = kind
53 && !id_is_string_or_integer(id)
54 {
55 sink.push(
56 Some(event.seq),
57 format!(
58 "request {method:?} carries {} as its id; the ID must be a string or an integer",
59 type_name(id)
60 ),
61 );
62 }
63 }
64}
65
66pub(super) fn request_id_not_null(context: &TraceContext<'_>, sink: &mut FindingSink) {
68 for (event, kind, _) in context.messages() {
69 if let MessageKind::Request { method, id } = kind
70 && id.is_null()
71 {
72 sink.push(
73 Some(event.seq),
74 format!("request {method:?} carries a null id, which MCP forbids"),
75 );
76 }
77 }
78}
79
80pub(super) fn request_id_unique(context: &TraceContext<'_>, sink: &mut FindingSink) {
83 let mut first_use: HashMap<(Direction, String), u64> = HashMap::new();
84 for (event, kind, _) in context.messages() {
85 if let MessageKind::Request { method, id } = kind {
86 if id.is_null() {
87 continue; }
89 let key = (event.direction, to_canonical_string(id));
90 match first_use.get(&key) {
91 Some(previous) => sink.push(
92 Some(event.seq),
93 format!(
94 "request {method:?} reuses id {}, already used by the same party at seq {previous}",
95 key.1
96 ),
97 ),
98 None => {
99 first_use.insert(key, event.seq);
100 }
101 }
102 }
103 }
104}
105
106fn responses_match_requests(
118 context: &TraceContext<'_>,
119 sink: &mut FindingSink,
120 want_results: bool,
121) {
122 let mut outstanding: HashMap<(Direction, String), u64> = HashMap::new();
124 for (event, kind, _) in context.messages() {
125 match kind {
126 MessageKind::Request { id, .. } => {
127 if !id.is_null() {
128 outstanding.insert((event.direction, to_canonical_string(id)), event.seq);
129 }
130 }
131 MessageKind::Result { id } => {
132 if want_results {
133 check_response_id(
134 event.seq,
135 event.direction,
136 *id,
137 &mut outstanding,
138 sink,
139 "result",
140 );
141 } else {
142 consume_outstanding(event.direction, *id, &mut outstanding);
145 }
146 }
147 MessageKind::Error { id, .. } => {
148 if want_results {
152 consume_outstanding(event.direction, *id, &mut outstanding);
153 } else if id.is_some_and(|id| !id.is_null()) {
154 check_response_id(
155 event.seq,
156 event.direction,
157 *id,
158 &mut outstanding,
159 sink,
160 "error",
161 );
162 }
163 }
164 _ => {}
165 }
166 }
167}
168
169fn consume_outstanding(
173 response_direction: Direction,
174 id: Option<&Value>,
175 outstanding: &mut HashMap<(Direction, String), u64>,
176) {
177 if let Some(id) = id.filter(|id| !id.is_null()) {
178 let requester = match response_direction {
179 Direction::ClientToServer => Direction::ServerToClient,
180 Direction::ServerToClient => Direction::ClientToServer,
181 };
182 outstanding.remove(&(requester, to_canonical_string(id)));
183 }
184}
185
186fn check_response_id(
187 seq: u64,
188 response_direction: Direction,
189 id: Option<&Value>,
190 outstanding: &mut HashMap<(Direction, String), u64>,
191 sink: &mut FindingSink,
192 flavor: &str,
193) {
194 let requester = match response_direction {
195 Direction::ClientToServer => Direction::ServerToClient,
196 Direction::ServerToClient => Direction::ClientToServer,
197 };
198 match id {
199 None => sink.push(
200 Some(seq),
201 format!("{flavor} response is missing its id; responses must echo the request id"),
202 ),
203 Some(id) if id.is_null() => sink.push(
204 Some(seq),
205 format!("{flavor} response carries a null id; responses must echo the request id"),
206 ),
207 Some(id) => {
208 let key = (requester, to_canonical_string(id));
209 if outstanding.remove(&key).is_none() {
210 sink.push(
211 Some(seq),
212 format!(
213 "{flavor} response answers id {}, but that party has no outstanding request with that id (never sent, or already answered)",
214 key.1
215 ),
216 );
217 }
218 }
219 }
220}
221
222pub(super) fn result_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
225 responses_match_requests(context, sink, true);
226}
227
228pub(super) fn error_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
231 responses_match_requests(context, sink, false);
232}
233
234pub(super) fn notification_no_id(context: &TraceContext<'_>, sink: &mut FindingSink) {
239 for (event, kind, _) in context.messages() {
240 if let MessageKind::Request { method, .. } = kind
241 && is_notification_method(method)
242 {
243 sink.push(
244 Some(event.seq),
245 format!(
246 "{method:?} is a notification method but the message carries an id; notifications must not include one"
247 ),
248 );
249 }
250 }
251}
252
253pub(super) fn error_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
256 for (event, kind, _) in context.messages() {
257 if let MessageKind::Error { error, .. } = kind {
258 let Some(object) = error.as_object() else {
259 sink.push(
260 Some(event.seq),
261 format!("error member is {}, expected an object", type_name(error)),
262 );
263 continue;
264 };
265 if !object.contains_key("code") {
266 sink.push(
267 Some(event.seq),
268 "error object lacks a code member".to_owned(),
269 );
270 }
271 match object.get("message") {
272 None => sink.push(
273 Some(event.seq),
274 "error object lacks a message member".to_owned(),
275 ),
276 Some(message) if !message.is_string() => sink.push(
277 Some(event.seq),
278 format!(
279 "error message member is {}, expected a string",
280 type_name(message)
281 ),
282 ),
283 Some(_) => {}
284 }
285 }
286 }
287}
288
289pub(super) fn error_code_integer(context: &TraceContext<'_>, sink: &mut FindingSink) {
291 for (event, kind, _) in context.messages() {
292 if let MessageKind::Error { error, .. } = kind
293 && let Some(code) = error.get("code")
294 && !code.is_i64()
295 && !code.is_u64()
296 {
297 sink.push(
298 Some(event.seq),
299 format!("error code is {}, expected an integer", type_name(code)),
300 );
301 }
302 }
303}
304
305pub(super) fn result_field(context: &TraceContext<'_>, sink: &mut FindingSink) {
310 for (event, kind, _) in context.messages() {
311 if !matches!(kind, MessageKind::Invalid { .. }) {
312 continue;
313 }
314 let Some(object) = event.message_payload().and_then(Value::as_object) else {
315 continue;
316 };
317 if object.contains_key("id")
318 && !object.contains_key("method")
319 && !object.contains_key("result")
320 && !object.contains_key("error")
321 {
322 sink.push(
323 Some(event.seq),
324 "response-shaped message (id present, no method) carries no result field"
325 .to_owned(),
326 );
327 }
328 }
329}
330
331pub(super) fn jsonrpc_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
335 for (event, kind, _) in context.messages() {
336 if let MessageKind::Invalid { reason } = kind {
337 sink.push(
338 Some(event.seq),
339 format!("message is not a JSON-RPC request, notification, or response: {reason}"),
340 );
341 continue;
342 }
343 let version = event
344 .message_payload()
345 .and_then(|payload| payload.get("jsonrpc"));
346 match version {
347 Some(Value::String(version)) if version == "2.0" => {}
348 Some(other) => sink.push(
349 Some(event.seq),
350 format!("jsonrpc member is {other}, expected the string \"2.0\""),
351 ),
352 None => sink.push(
353 Some(event.seq),
354 "message lacks the jsonrpc member; JSON-RPC 2.0 requires \"jsonrpc\": \"2.0\""
355 .to_owned(),
356 ),
357 }
358 }
359}
360
361#[cfg(test)]
362#[allow(clippy::unwrap_used)]
363mod tests {
364 use crate::checks;
365 use crate::context::TraceContext;
366 use crate::reader::{Limits, parse_trace};
367 use crate::report::Finding;
368 use mcp_conformance_core::trace::TraceEvent;
369
370 fn run_check(check_id: &str, trace: &str) -> Vec<Finding> {
371 let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
372 let context = TraceContext::new(&events);
373 checks::find(check_id).unwrap().run(&context)
374 }
375
376 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"}}}}"#;
377
378 #[test]
379 fn result_response_with_null_id_gets_the_null_detail() {
380 let trace = format!(
382 "{INIT}\n{}",
383 r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":null,"result":{}}}"#
384 );
385 let findings = run_check("base.result-id-matches", &trace);
386 assert_eq!(findings.len(), 1);
387 assert!(
388 findings[0].detail.contains("null id"),
389 "{}",
390 findings[0].detail
391 );
392 }
393
394 #[test]
395 fn error_message_member_type_is_named_precisely() {
396 let trace = format!(
399 "{INIT}\n{}",
400 r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":-5}}}"#
401 );
402 let findings = run_check("base.error-shape", &trace);
403 assert_eq!(findings.len(), 1);
404 assert!(
405 findings[0]
406 .detail
407 .contains("is an integer, expected a string"),
408 "{}",
409 findings[0].detail
410 );
411 }
412
413 #[test]
414 fn u64_only_request_ids_are_valid_integers() {
415 let trace = format!(
417 "{INIT}\n{}",
418 r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":18446744073709551615,"method":"tools/list"}}"#
419 );
420 assert!(run_check("base.request-id-type", &trace).is_empty());
421 }
422
423 const REQUEST: &str = r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#;
428 const RESULT_2: &str = r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{}}}"#;
429 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"}}}"#;
430
431 #[test]
432 fn error_then_result_flags_the_second_answer_as_a_result() {
433 let trace = format!("{INIT}\n{REQUEST}\n{ERROR_2}\n{RESULT_2}");
436 let results = run_check("base.result-id-matches", &trace);
437 assert_eq!(results.len(), 1, "{results:?}");
438 assert_eq!(results[0].seq, Some(3));
439 assert!(
440 results[0].detail.contains("already answered"),
441 "{results:?}"
442 );
443 assert!(
444 run_check("base.error-id-matches", &trace).is_empty(),
445 "the error was the legitimate first answer"
446 );
447 }
448
449 #[test]
450 fn result_then_error_flags_the_second_answer_as_an_error() {
451 let result_seq2 = RESULT_2.replace("\"seq\":3", "\"seq\":2");
454 let error_seq3 = ERROR_2.replace("\"seq\":2", "\"seq\":3");
455 let trace = format!("{INIT}\n{REQUEST}\n{result_seq2}\n{error_seq3}");
456 let errors = run_check("base.error-id-matches", &trace);
457 assert_eq!(errors.len(), 1, "{errors:?}");
458 assert_eq!(errors[0].seq, Some(3));
459 assert!(errors[0].detail.contains("already answered"), "{errors:?}");
460 assert!(
461 run_check("base.result-id-matches", &trace).is_empty(),
462 "the result was the legitimate first answer"
463 );
464 }
465
466 #[test]
467 fn single_flavor_answer_is_not_flagged_by_the_other_pass() {
468 let trace = format!(
471 "{INIT}\n{REQUEST}\n{}",
472 RESULT_2.replace("\"seq\":3", "\"seq\":2")
473 );
474 assert!(run_check("base.result-id-matches", &trace).is_empty());
475 assert!(run_check("base.error-id-matches", &trace).is_empty());
476 }
477}