Skip to main content

mcp_trace_validator/checks/
base.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the base JSON-RPC message requirements (`BASE-*`).
5//!
6//! These operate per message and rely on [`classify`]'s leniency: malformed messages
7//! are reported precisely rather than aborting the run.
8
9use 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
23/// Human name of a JSON value's type, for finding details.
24fn 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
49/// `BASE-001`: "Requests MUST include a string or integer ID."
50pub(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
66/// `BASE-002`: "Unlike base JSON-RPC, the ID MUST NOT be `null`."
67pub(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
80/// `BASE-003`: "The request ID MUST NOT have been previously used by the requestor
81/// within the same session."
82pub(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; // BASE-002's finding; don't double-report.
88            }
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
106/// Walks responses and reports those of the wanted flavor that match no outstanding
107/// request from the opposite party. Shared by `BASE-004` (results) and `BASE-009`
108/// (errors).
109///
110/// A request is answered exactly once, by a result XOR an error. Each flavor's pass
111/// therefore consumes the outstanding entry on *both* flavors — its own (flagging a
112/// mismatch) and the other's (silently, as that other response is the legitimate
113/// first answer). The consequence: a request answered by both a result and an error,
114/// in either order, leaves the *second* response with no outstanding request, and the
115/// pass for the second response's flavor flags it. Without the cross-flavor consume,
116/// each pass saw a clean 1-request→1-response and a double-answer slipped through.
117fn responses_match_requests(
118    context: &TraceContext<'_>,
119    sink: &mut FindingSink,
120    want_results: bool,
121) {
122    // Outstanding request ids per requesting party, canonical id -> request seq.
123    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                    // The other flavor's valid first answer: consume so a later
143                    // same-id error is seen as answering an already-answered request.
144                    consume_outstanding(event.direction, *id, &mut outstanding);
145                }
146            }
147            MessageKind::Error { id, .. } => {
148                // The null/absent-id condition is the spec's escape hatch ("except in
149                // error cases where the ID could not be read due a malformed request"),
150                // so a null/absent error id is neither flagged nor consumes anything.
151                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
169/// Removes the outstanding request a response answers, without flagging — the path
170/// for a response of the flavor a given pass does not judge. A null/absent id matches
171/// no request and removes nothing.
172fn 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
222/// `BASE-004`: "Result responses MUST include the same ID as the request they
223/// correspond to."
224pub(super) fn result_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
225    responses_match_requests(context, sink, true);
226}
227
228/// `BASE-009`: "Error responses MUST include the same ID as the request they correspond
229/// to (except in error cases where the ID could not be read due a malformed request)."
230pub(super) fn error_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
231    responses_match_requests(context, sink, false);
232}
233
234/// `BASE-005`: "Notifications MUST NOT include an ID."
235///
236/// A message in the reserved `notifications/` namespace that carries an `id`
237/// classifies structurally as a request; this check is what catches it.
238pub(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
253/// `BASE-006`: "Error responses MUST include an `error` field with a `code` and
254/// `message`."
255pub(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
289/// `BASE-007`: "Error codes MUST be integers."
290pub(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
305/// `BASE-010`: "Result responses MUST include a `result` field." A message carrying
306/// an `id` and no `method` is response-shaped; if it then carries neither `result`
307/// nor `error`, it is a result response missing its `result` member (an error
308/// response would carry `error` instead).
309pub(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
331/// `BASE-008`: "All messages between MCP clients and servers MUST follow the JSON-RPC
332/// 2.0 specification." — verified here as: the message classifies as a JSON-RPC shape
333/// and carries `"jsonrpc": "2.0"`.
334pub(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        // A null-id result is its own finding, distinct from "no outstanding request".
381        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        // -5 is i64-but-not-u64: the finding must call it an integer, which pins the
397        // is_i64 || is_u64 disjunction in type_name.
398        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        // u64::MAX is not representable as i64; it must still count as an integer id.
416        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    /// A request id=2 answered by both an error and a result. The SECOND answer
424    /// has no outstanding request and must be flagged by its own flavor's check;
425    /// the cross-flavor consume is what makes that true (without it both checks
426    /// saw a clean 1:1 and the double-answer slipped through).
427    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        // error (seq2) then result (seq3): the result is the double-answer, so
434        // BASE-004 flags it and BASE-009 stays silent (the error was valid).
435        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        // Reverse order, so the fix cannot be order-specific: result (seq2) then
452        // error (seq3) makes the error the double-answer.
453        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        // Guard against a cross-flavor consume that over-fires: a request
469        // answered once by a result must leave BOTH passes clean.
470        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}