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}