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//!
9//! Two groups live in submodules because they carry machinery of their own:
10//! [`meta`] holds the `_meta` key grammar, and [`correlation`] the shared walk
11//! that answers `BASE-004` and `BASE-009` together.
12
13use 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// The `_meta` key grammar is unchanged at `2026-07-28`, where VERS-004 reuses it
29// for extension identifiers — the function, not `base.meta-key-format`, which
30// reads envelope keys and would inspect no identifier at all.
31#[cfg(feature = "draft-2026-07-28")]
32pub(super) use meta::validate_meta_key;
33
34/// Human name of a JSON value's type, for finding details.
35fn 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
60/// `BASE-001`: "Requests MUST include a string or integer ID."
61pub(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
79/// `BASE-002`: "Unlike base JSON-RPC, the ID MUST NOT be `null`."
80pub(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
95/// `BASE-003`: "The request ID MUST NOT have been previously used by the requestor
96/// within the same session."
97pub(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; // BASE-002's finding; don't double-report.
103            }
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
122/// `BASE-005`: "Notifications MUST NOT include an ID."
123///
124/// A message in the reserved `notifications/` namespace that carries an `id`
125/// classifies structurally as a request; this check is what catches it.
126pub(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
143/// `BASE-006`: "Error responses MUST include an `error` field with a `code` and
144/// `message`."
145pub(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
180/// `BASE-007`: "Error codes MUST be integers."
181pub(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        // The subject is an error *carrying a code*: an error with none is
187        // BASE-006's finding, and this clause has nothing to judge there.
188        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
201/// `BASE-010`: "Result responses MUST include a `result` field." A message carrying
202/// an `id` and no `method` is response-shaped; if it then carries neither `result`
203/// nor `error`, it is a result response missing its `result` member (an error
204/// response would carry `error` instead).
205///
206/// `Result`-classified messages are subjects too, and counting them is the point:
207/// they carry the member, so they are this clause *complied with*. Examining only
208/// the `Invalid` ones left the check unable to report a pass at all — a session
209/// full of well-formed results reported `not observed`, which says the trace
210/// carried nothing this clause binds to and was plainly untrue. An outcome a
211/// check can never reach is a check nothing proves accepts conforming input.
212pub(super) fn result_field(context: &TraceContext<'_>, sink: &mut FindingSink) {
213    for (event, kind, _) in context.messages() {
214        // An `Error` response is deliberately not a subject: the clause binds
215        // *result* responses, and an error legitimately carries no `result`.
216        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        // One member, not both, and the classifier is why. A message reaching
230        // here is `Invalid` with an `id` and no `method`, and `classify`'s own
231        // table leaves exactly two ways for that to happen: it carries *both*
232        // `result` and `error` (ambiguous) or *neither* (this clause's
233        // finding). The two tests can therefore never disagree, so asking both
234        // is a condition no trace can vary independently — dead weight that
235        // reads as thoroughness. The mutation gate found it.
236        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
246/// `BASE-008`: "All messages between MCP clients and servers MUST follow the JSON-RPC
247/// 2.0 specification." — verified here as: the message classifies as a JSON-RPC shape
248/// and carries `"jsonrpc": "2.0"`.
249pub(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        // The two cases `classify` can hand this check, and the reason it only
297        // asks about `result`: an id-bearing, method-less message that it
298        // called `Invalid` carries both members or neither.
299        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        // Both: ambiguous, and BASE-006's business rather than this clause's —
308        // whatever else is wrong with it, a `result` member is present.
309        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        // A well-formed result classifies as `Result` and never reaches here.
313        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        // A null-id result is its own finding, distinct from "no outstanding request".
320        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        // -5 is i64-but-not-u64: the finding must call it an integer, which pins the
336        // is_i64 || is_u64 disjunction in type_name.
337        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        // u64::MAX is not representable as i64; it must still count as an integer id.
355        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    /// A request id=2 answered by both an error and a result. The SECOND answer
363    /// has no outstanding request and must be flagged by its own flavor's check;
364    /// the cross-flavor consume is what makes that true (without it both checks
365    /// saw a clean 1:1 and the double-answer slipped through).
366    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        // error (seq2) then result (seq3): the result is the double-answer, so
373        // BASE-004 flags it and BASE-009 stays silent (the error was valid).
374        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        // Reverse order, so the fix cannot be order-specific: result (seq2) then
391        // error (seq3) makes the error the double-answer.
392        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        // Guard against a cross-flavor consume that over-fires: a request
408        // answered once by a result must leave BOTH passes clean.
409        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}