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
19/// Human name of a JSON value's type, for finding details.
20fn 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
45/// `BASE-001`: "Requests MUST include a string or integer ID."
46pub(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
62/// `BASE-002`: "Unlike base JSON-RPC, the ID MUST NOT be `null`."
63pub(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
76/// `BASE-003`: "The request ID MUST NOT have been previously used by the requestor
77/// within the same session."
78pub(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; // BASE-002's finding; don't double-report.
84            }
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
102/// Walks responses of one flavor and reports those that match no outstanding request
103/// from the opposite party. Shared by `BASE-004` (results) and `BASE-009` (errors).
104fn responses_match_requests(
105    context: &TraceContext<'_>,
106    sink: &mut FindingSink,
107    want_results: bool,
108) {
109    // Outstanding request ids per requesting party, canonical id -> request seq.
110    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            // The null/absent-id condition lives in the guard: "except in error cases
129            // where the ID could not be read due a malformed request" — an absent or
130            // null id is the spec's escape hatch, not a violation this check can judge.
131            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
184/// `BASE-004`: "Result responses MUST include the same ID as the request they
185/// correspond to."
186pub(super) fn result_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
187    responses_match_requests(context, sink, true);
188}
189
190/// `BASE-009`: "Error responses MUST include the same ID as the request they correspond
191/// to (except in error cases where the ID could not be read due a malformed request)."
192pub(super) fn error_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
193    responses_match_requests(context, sink, false);
194}
195
196/// `BASE-005`: "Notifications MUST NOT include an ID."
197///
198/// A message in the reserved `notifications/` namespace that carries an `id`
199/// classifies structurally as a request; this check is what catches it.
200pub(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
215/// `BASE-006`: "Error responses MUST include an `error` field with a `code` and
216/// `message`."
217pub(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
251/// `BASE-007`: "Error codes MUST be integers."
252pub(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
267/// `BASE-010`: "Result responses MUST include a `result` field." A message carrying
268/// an `id` and no `method` is response-shaped; if it then carries neither `result`
269/// nor `error`, it is a result response missing its `result` member (an error
270/// response would carry `error` instead).
271pub(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
293/// `BASE-008`: "All messages between MCP clients and servers MUST follow the JSON-RPC
294/// 2.0 specification." — verified here as: the message classifies as a JSON-RPC shape
295/// and carries `"jsonrpc": "2.0"`.
296pub(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        // A null-id result is its own finding, distinct from "no outstanding request".
343        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        // -5 is i64-but-not-u64: the finding must call it an integer, which pins the
359        // is_i64 || is_u64 disjunction in type_name.
360        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        // u64::MAX is not representable as i64; it must still count as an integer id.
378        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}