Skip to main content

mcp_trace_validator/checks/base/
correlation.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Response correlation: `BASE-004` and `BASE-009`, and the one walk that
5//! answers both.
6//!
7//! Split from [`super`] because the two clauses cannot be judged
8//! independently. A request is answered exactly once, by a result *xor* an
9//! error, so deciding whether a result is unsolicited requires knowing which
10//! errors have already consumed their requests — and vice versa. The shared
11//! walk below is that bookkeeping, and it is long enough to deserve reading on
12//! its own.
13
14use std::collections::HashMap;
15
16use mcp_conformance_core::canonical::to_canonical_string;
17use mcp_conformance_core::message::MessageKind;
18use mcp_conformance_core::trace::Direction;
19use serde_json::Value;
20
21use super::super::FindingSink;
22use crate::context::TraceContext;
23
24/// Walks responses and reports those of the wanted flavor that match no outstanding
25/// request from the opposite party. Shared by `BASE-004` (results) and `BASE-009`
26/// (errors).
27///
28/// A request is answered exactly once, by a result XOR an error. Each flavor's pass
29/// therefore consumes the outstanding entry on *both* flavors — its own (flagging a
30/// mismatch) and the other's (silently, as that other response is the legitimate
31/// first answer). The consequence: a request answered by both a result and an error,
32/// in either order, leaves the *second* response with no outstanding request, and the
33/// pass for the second response's flavor flags it. Without the cross-flavor consume,
34/// each pass saw a clean 1-request→1-response and a double-answer slipped through.
35fn responses_match_requests(
36    context: &TraceContext<'_>,
37    sink: &mut FindingSink,
38    want_results: bool,
39) {
40    // Outstanding request ids per requesting party, canonical id -> request seq.
41    let mut outstanding: HashMap<(Direction, String), u64> = HashMap::new();
42    for (event, kind, _) in context.messages() {
43        match kind {
44            MessageKind::Request { id, .. } => {
45                if !id.is_null() {
46                    outstanding.insert((event.direction, to_canonical_string(id)), event.seq);
47                }
48            }
49            MessageKind::Result { id } => {
50                if want_results {
51                    // The subject is a response of the flavour this pass
52                    // judges; the other flavour is consumed silently and is
53                    // not this clause's business.
54                    sink.examined();
55                    check_response_id(
56                        event.seq,
57                        event.direction,
58                        *id,
59                        &mut outstanding,
60                        sink,
61                        "result",
62                    );
63                } else {
64                    // The other flavor's valid first answer: consume so a later
65                    // same-id error is seen as answering an already-answered request.
66                    consume_outstanding(event.direction, *id, &mut outstanding);
67                }
68            }
69            MessageKind::Error { id, .. } => {
70                // The null/absent-id condition is the spec's escape hatch ("except in
71                // error cases where the ID could not be read due a malformed request"),
72                // so a null/absent error id is neither flagged nor consumes anything.
73                if want_results {
74                    consume_outstanding(event.direction, *id, &mut outstanding);
75                } else if id.is_some_and(|id| !id.is_null()) {
76                    sink.examined();
77                    check_response_id(
78                        event.seq,
79                        event.direction,
80                        *id,
81                        &mut outstanding,
82                        sink,
83                        "error",
84                    );
85                }
86            }
87            _ => {}
88        }
89    }
90}
91
92/// Removes the outstanding request a response answers, without flagging — the path
93/// for a response of the flavor a given pass does not judge. A null/absent id matches
94/// no request and removes nothing.
95fn consume_outstanding(
96    response_direction: Direction,
97    id: Option<&Value>,
98    outstanding: &mut HashMap<(Direction, String), u64>,
99) {
100    if let Some(id) = id.filter(|id| !id.is_null()) {
101        let requester = match response_direction {
102            Direction::ClientToServer => Direction::ServerToClient,
103            Direction::ServerToClient => Direction::ClientToServer,
104        };
105        outstanding.remove(&(requester, to_canonical_string(id)));
106    }
107}
108
109fn check_response_id(
110    seq: u64,
111    response_direction: Direction,
112    id: Option<&Value>,
113    outstanding: &mut HashMap<(Direction, String), u64>,
114    sink: &mut FindingSink,
115    flavor: &str,
116) {
117    let requester = match response_direction {
118        Direction::ClientToServer => Direction::ServerToClient,
119        Direction::ServerToClient => Direction::ClientToServer,
120    };
121    match id {
122        None => sink.push(
123            Some(seq),
124            format!("{flavor} response is missing its id; responses must echo the request id"),
125        ),
126        Some(id) if id.is_null() => sink.push(
127            Some(seq),
128            format!("{flavor} response carries a null id; responses must echo the request id"),
129        ),
130        Some(id) => {
131            let key = (requester, to_canonical_string(id));
132            if outstanding.remove(&key).is_none() {
133                sink.push(
134                    Some(seq),
135                    format!(
136                        "{flavor} response answers id {}, but that party has no outstanding request with that id (never sent, or already answered)",
137                        key.1
138                    ),
139                );
140            }
141        }
142    }
143}
144
145/// `BASE-004`: "Result responses MUST include the same ID as the request they
146/// correspond to."
147pub(in crate::checks) fn result_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
148    responses_match_requests(context, sink, true);
149}
150
151/// `BASE-009`: "Error responses MUST include the same ID as the request they correspond
152/// to (except in error cases where the ID could not be read due a malformed request)."
153pub(in crate::checks) fn error_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
154    responses_match_requests(context, sink, false);
155}