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
19fn type_name(value: &Value) -> &'static str {
21 match value {
22 Value::Null => "null",
23 Value::Bool(_) => "a boolean",
24 Value::Number(number) => {
25 if number.is_i64() || number.is_u64() {
26 "an integer"
27 } else {
28 "a non-integer number"
29 }
30 }
31 Value::String(_) => "a string",
32 Value::Array(_) => "an array",
33 Value::Object(_) => "an object",
34 }
35}
36
37fn id_is_string_or_integer(id: &Value) -> bool {
38 match id {
39 Value::String(_) => true,
40 Value::Number(number) => number.is_i64() || number.is_u64(),
41 _ => false,
42 }
43}
44
45pub(super) fn request_id_type(context: &TraceContext<'_>, sink: &mut FindingSink) {
47 for (event, kind, _) in context.messages() {
48 if let MessageKind::Request { method, id } = kind {
49 if !id_is_string_or_integer(id) {
50 sink.push(
51 Some(event.seq),
52 format!(
53 "request {method:?} carries {} as its id; the ID must be a string or an integer",
54 type_name(id)
55 ),
56 );
57 }
58 }
59 }
60}
61
62pub(super) fn request_id_not_null(context: &TraceContext<'_>, sink: &mut FindingSink) {
64 for (event, kind, _) in context.messages() {
65 if let MessageKind::Request { method, id } = kind {
66 if id.is_null() {
67 sink.push(
68 Some(event.seq),
69 format!("request {method:?} carries a null id, which MCP forbids"),
70 );
71 }
72 }
73 }
74}
75
76pub(super) fn request_id_unique(context: &TraceContext<'_>, sink: &mut FindingSink) {
79 let mut first_use: HashMap<(Direction, String), u64> = HashMap::new();
80 for (event, kind, _) in context.messages() {
81 if let MessageKind::Request { method, id } = kind {
82 if id.is_null() {
83 continue; }
85 let key = (event.direction, to_canonical_string(id));
86 match first_use.get(&key) {
87 Some(previous) => sink.push(
88 Some(event.seq),
89 format!(
90 "request {method:?} reuses id {}, already used by the same party at seq {previous}",
91 key.1
92 ),
93 ),
94 None => {
95 first_use.insert(key, event.seq);
96 }
97 }
98 }
99 }
100}
101
102fn responses_match_requests(
105 context: &TraceContext<'_>,
106 sink: &mut FindingSink,
107 want_results: bool,
108) {
109 let mut outstanding: HashMap<(Direction, String), u64> = HashMap::new();
111 for (event, kind, _) in context.messages() {
112 match kind {
113 MessageKind::Request { id, .. } => {
114 if !id.is_null() {
115 outstanding.insert((event.direction, to_canonical_string(id)), event.seq);
116 }
117 }
118 MessageKind::Result { id } if want_results => {
119 check_response_id(
120 event.seq,
121 event.direction,
122 *id,
123 &mut outstanding,
124 sink,
125 "result",
126 );
127 }
128 MessageKind::Error { id, .. }
132 if !want_results && id.is_some_and(|id| !id.is_null()) =>
133 {
134 check_response_id(
135 event.seq,
136 event.direction,
137 *id,
138 &mut outstanding,
139 sink,
140 "error",
141 );
142 }
143 _ => {}
144 }
145 }
146}
147
148fn check_response_id(
149 seq: u64,
150 response_direction: Direction,
151 id: Option<&Value>,
152 outstanding: &mut HashMap<(Direction, String), u64>,
153 sink: &mut FindingSink,
154 flavor: &str,
155) {
156 let requester = match response_direction {
157 Direction::ClientToServer => Direction::ServerToClient,
158 Direction::ServerToClient => Direction::ClientToServer,
159 };
160 match id {
161 None => sink.push(
162 Some(seq),
163 format!("{flavor} response is missing its id; responses must echo the request id"),
164 ),
165 Some(id) if id.is_null() => sink.push(
166 Some(seq),
167 format!("{flavor} response carries a null id; responses must echo the request id"),
168 ),
169 Some(id) => {
170 let key = (requester, to_canonical_string(id));
171 if outstanding.remove(&key).is_none() {
172 sink.push(
173 Some(seq),
174 format!(
175 "{flavor} response answers id {}, but that party has no outstanding request with that id (never sent, or already answered)",
176 key.1
177 ),
178 );
179 }
180 }
181 }
182}
183
184pub(super) fn result_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
187 responses_match_requests(context, sink, true);
188}
189
190pub(super) fn error_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
193 responses_match_requests(context, sink, false);
194}
195
196pub(super) fn notification_no_id(context: &TraceContext<'_>, sink: &mut FindingSink) {
201 for (event, kind, _) in context.messages() {
202 if let MessageKind::Request { method, .. } = kind {
203 if is_notification_method(method) {
204 sink.push(
205 Some(event.seq),
206 format!(
207 "{method:?} is a notification method but the message carries an id; notifications must not include one"
208 ),
209 );
210 }
211 }
212 }
213}
214
215pub(super) fn error_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
218 for (event, kind, _) in context.messages() {
219 if let MessageKind::Error { error, .. } = kind {
220 let Some(object) = error.as_object() else {
221 sink.push(
222 Some(event.seq),
223 format!("error member is {}, expected an object", type_name(error)),
224 );
225 continue;
226 };
227 if !object.contains_key("code") {
228 sink.push(
229 Some(event.seq),
230 "error object lacks a code member".to_owned(),
231 );
232 }
233 match object.get("message") {
234 None => sink.push(
235 Some(event.seq),
236 "error object lacks a message member".to_owned(),
237 ),
238 Some(message) if !message.is_string() => sink.push(
239 Some(event.seq),
240 format!(
241 "error message member is {}, expected a string",
242 type_name(message)
243 ),
244 ),
245 Some(_) => {}
246 }
247 }
248 }
249}
250
251pub(super) fn error_code_integer(context: &TraceContext<'_>, sink: &mut FindingSink) {
253 for (event, kind, _) in context.messages() {
254 if let MessageKind::Error { error, .. } = kind {
255 if let Some(code) = error.get("code") {
256 if !code.is_i64() && !code.is_u64() {
257 sink.push(
258 Some(event.seq),
259 format!("error code is {}, expected an integer", type_name(code)),
260 );
261 }
262 }
263 }
264 }
265}
266
267pub(super) fn result_field(context: &TraceContext<'_>, sink: &mut FindingSink) {
272 for (event, kind, _) in context.messages() {
273 if !matches!(kind, MessageKind::Invalid { .. }) {
274 continue;
275 }
276 let Some(object) = event.message_payload().and_then(Value::as_object) else {
277 continue;
278 };
279 if object.contains_key("id")
280 && !object.contains_key("method")
281 && !object.contains_key("result")
282 && !object.contains_key("error")
283 {
284 sink.push(
285 Some(event.seq),
286 "response-shaped message (id present, no method) carries no result field"
287 .to_owned(),
288 );
289 }
290 }
291}
292
293pub(super) fn jsonrpc_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
297 for (event, kind, _) in context.messages() {
298 if let MessageKind::Invalid { reason } = kind {
299 sink.push(
300 Some(event.seq),
301 format!("message is not a JSON-RPC request, notification, or response: {reason}"),
302 );
303 continue;
304 }
305 let version = event
306 .message_payload()
307 .and_then(|payload| payload.get("jsonrpc"));
308 match version {
309 Some(Value::String(version)) if version == "2.0" => {}
310 Some(other) => sink.push(
311 Some(event.seq),
312 format!("jsonrpc member is {other}, expected the string \"2.0\""),
313 ),
314 None => sink.push(
315 Some(event.seq),
316 "message lacks the jsonrpc member; JSON-RPC 2.0 requires \"jsonrpc\": \"2.0\""
317 .to_owned(),
318 ),
319 }
320 }
321}
322
323#[cfg(test)]
324#[allow(clippy::unwrap_used)]
325mod tests {
326 use crate::checks;
327 use crate::context::TraceContext;
328 use crate::reader::{Limits, parse_trace};
329 use crate::report::Finding;
330 use mcp_conformance_core::trace::TraceEvent;
331
332 fn run_check(check_id: &str, trace: &str) -> Vec<Finding> {
333 let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
334 let context = TraceContext::new(&events);
335 checks::find(check_id).unwrap().run(&context)
336 }
337
338 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"}}}}"#;
339
340 #[test]
341 fn result_response_with_null_id_gets_the_null_detail() {
342 let trace = format!(
344 "{INIT}\n{}",
345 r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":null,"result":{}}}"#
346 );
347 let findings = run_check("base.result-id-matches", &trace);
348 assert_eq!(findings.len(), 1);
349 assert!(
350 findings[0].detail.contains("null id"),
351 "{}",
352 findings[0].detail
353 );
354 }
355
356 #[test]
357 fn error_message_member_type_is_named_precisely() {
358 let trace = format!(
361 "{INIT}\n{}",
362 r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":-5}}}"#
363 );
364 let findings = run_check("base.error-shape", &trace);
365 assert_eq!(findings.len(), 1);
366 assert!(
367 findings[0]
368 .detail
369 .contains("is an integer, expected a string"),
370 "{}",
371 findings[0].detail
372 );
373 }
374
375 #[test]
376 fn u64_only_request_ids_are_valid_integers() {
377 let trace = format!(
379 "{INIT}\n{}",
380 r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":18446744073709551615,"method":"tools/list"}}"#
381 );
382 assert!(run_check("base.request-id-type", &trace).is_empty());
383 }
384}