Skip to main content

mcp_trace_validator/context/
pairing.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Request/response pairing: which request each response answers.
5//!
6//! Feature-area checks judge *exchanges* — "the result of a `tools/list` request MUST
7//! …" — so the context precomputes, in one deterministic pass, the request event each
8//! result or error response corresponds to. Pairing is deliberately lenient about
9//! everything other checks already police: a duplicate request ID still pairs (with the
10//! earliest unanswered request, the only reading that lets later checks point at both
11//! events), and a response nobody asked for simply pairs with nothing.
12
13use mcp_conformance_core::message::MessageKind;
14use mcp_conformance_core::trace::TraceEvent;
15use serde_json::Value;
16
17use super::TraceContext;
18
19/// One completed request/response exchange, in response order.
20#[derive(Debug, Clone, Copy)]
21#[non_exhaustive]
22pub struct Exchange<'a> {
23    /// The request event.
24    pub request: &'a TraceEvent,
25    /// The request's `method`.
26    pub method: &'a str,
27    /// The request's `params`, when present.
28    pub params: Option<&'a Value>,
29    /// The response event (result or error).
30    pub response: &'a TraceEvent,
31    /// The response's `result` value; `None` when the response is an error.
32    pub result: Option<&'a Value>,
33}
34
35/// For each event, the index of the request it answers — `Some` only for result/error
36/// responses whose ID matches an earlier unanswered request from the opposite
37/// direction.
38pub(super) fn pair_responses(
39    events: &[TraceEvent],
40    kinds: &[Option<MessageKind<'_>>],
41) -> Vec<Option<usize>> {
42    let mut open: Vec<usize> = Vec::new();
43    let mut pairs = vec![None; events.len()];
44    for (index, (event, kind)) in events.iter().zip(kinds).enumerate() {
45        match kind {
46            Some(MessageKind::Request { .. }) => open.push(index),
47            Some(
48                MessageKind::Result { id: Some(id) } | MessageKind::Error { id: Some(id), .. },
49            ) => {
50                let answered = open.iter().position(|&request_index| {
51                    let request = &events[request_index];
52                    request.direction != event.direction
53                        && matches!(
54                            &kinds[request_index],
55                            Some(MessageKind::Request { id: request_id, .. })
56                                if *request_id == *id
57                        )
58                });
59                if let Some(position) = answered {
60                    pairs[index] = Some(open.remove(position));
61                }
62            }
63            _ => {}
64        }
65    }
66    pairs
67}
68
69impl<'a> TraceContext<'a> {
70    /// Iterates completed request/response exchanges, in response order.
71    pub fn exchanges(&self) -> impl Iterator<Item = Exchange<'a>> + '_ {
72        self.pairs
73            .iter()
74            .enumerate()
75            .filter_map(move |(index, request_index)| {
76                let request_index = (*request_index)?;
77                let request = &self.events[request_index];
78                let Some(MessageKind::Request { method, .. }) = &self.kinds[request_index] else {
79                    return None;
80                };
81                let response = &self.events[index];
82                Some(Exchange {
83                    request,
84                    method,
85                    params: request
86                        .message_payload()
87                        .and_then(|payload| payload.get("params")),
88                    response,
89                    result: response
90                        .message_payload()
91                        .and_then(|payload| payload.get("result")),
92                })
93            })
94    }
95
96    /// Completed exchanges for one request method — the shape feature-area checks
97    /// want: "every `tools/list` result MUST …".
98    pub fn exchanges_for(&self, method: &'a str) -> impl Iterator<Item = Exchange<'a>> + '_ {
99        self.exchanges()
100            .filter(move |exchange| exchange.method == method)
101    }
102}
103
104#[cfg(test)]
105#[allow(clippy::unwrap_used)]
106mod tests {
107    use crate::context::TraceContext;
108    use crate::reader::{Limits, parse_trace};
109    use mcp_conformance_core::trace::TraceEvent;
110
111    fn line(seq: u64, direction: &str, payload: &str) -> String {
112        format!(
113            r#"{{"seq":{seq},"direction":"{direction}","transport":"stdio","kind":"message","payload":{payload}}}"#
114        )
115    }
116
117    fn events_of(lines: &[String]) -> Vec<TraceEvent> {
118        parse_trace(&lines.join("\n"), &Limits::default()).unwrap()
119    }
120
121    #[test]
122    fn pairs_results_and_errors_with_their_requests() {
123        let events = events_of(&[
124            line(
125                0,
126                "client-to-server",
127                r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
128            ),
129            line(
130                1,
131                "client-to-server",
132                r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo"}}"#,
133            ),
134            line(
135                2,
136                "server-to-client",
137                r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"x"}}"#,
138            ),
139            line(
140                3,
141                "server-to-client",
142                r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#,
143            ),
144        ]);
145        let context = TraceContext::new(&events);
146        let exchanges: Vec<_> = context.exchanges().collect();
147        assert_eq!(exchanges.len(), 2);
148        // Response order: the error to id 2 comes first.
149        assert_eq!(exchanges[0].method, "tools/call");
150        assert_eq!(exchanges[0].request.seq, 1);
151        assert_eq!(exchanges[0].response.seq, 2);
152        assert!(
153            exchanges[0].result.is_none(),
154            "error responses have no result"
155        );
156        assert!(exchanges[0].params.is_some());
157        assert_eq!(exchanges[1].method, "tools/list");
158        assert_eq!(exchanges[1].response.seq, 3);
159        assert_eq!(
160            exchanges[1].result.unwrap(),
161            &serde_json::json!({"tools": []})
162        );
163        assert!(exchanges[1].params.is_none());
164    }
165
166    #[test]
167    fn responses_pair_only_against_the_opposite_direction() {
168        // A "response" travelling the same way as the request answers nothing.
169        let events = events_of(&[
170            line(
171                0,
172                "client-to-server",
173                r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
174            ),
175            line(
176                1,
177                "client-to-server",
178                r#"{"jsonrpc":"2.0","id":1,"result":{}}"#,
179            ),
180        ]);
181        let context = TraceContext::new(&events);
182        assert_eq!(context.exchanges().count(), 0);
183    }
184
185    #[test]
186    fn duplicate_request_ids_pair_earliest_first() {
187        let events = events_of(&[
188            line(
189                0,
190                "client-to-server",
191                r#"{"jsonrpc":"2.0","id":7,"method":"ping"}"#,
192            ),
193            line(
194                1,
195                "client-to-server",
196                r#"{"jsonrpc":"2.0","id":7,"method":"tools/list"}"#,
197            ),
198            line(
199                2,
200                "server-to-client",
201                r#"{"jsonrpc":"2.0","id":7,"result":{}}"#,
202            ),
203            line(
204                3,
205                "server-to-client",
206                r#"{"jsonrpc":"2.0","id":7,"result":{"tools":[]}}"#,
207            ),
208        ]);
209        let context = TraceContext::new(&events);
210        let methods: Vec<&str> = context
211            .exchanges()
212            .map(|exchange| exchange.method)
213            .collect();
214        assert_eq!(methods, ["ping", "tools/list"]);
215    }
216
217    #[test]
218    fn unanswered_and_unsolicited_messages_pair_with_nothing() {
219        let events = events_of(&[
220            line(
221                0,
222                "client-to-server",
223                r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
224            ),
225            line(
226                1,
227                "server-to-client",
228                r#"{"jsonrpc":"2.0","id":99,"result":{}}"#,
229            ),
230            line(2, "server-to-client", r#"{"jsonrpc":"2.0","result":{}}"#),
231            line(
232                3,
233                "client-to-server",
234                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
235            ),
236        ]);
237        let context = TraceContext::new(&events);
238        assert_eq!(context.exchanges().count(), 0);
239    }
240
241    #[test]
242    fn an_id_is_answered_once_then_reopens_for_nothing() {
243        // After a request is answered, a second response with the same id pairs with
244        // nothing (the request is no longer open).
245        let events = events_of(&[
246            line(
247                0,
248                "client-to-server",
249                r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#,
250            ),
251            line(
252                1,
253                "server-to-client",
254                r#"{"jsonrpc":"2.0","id":1,"result":{}}"#,
255            ),
256            line(
257                2,
258                "server-to-client",
259                r#"{"jsonrpc":"2.0","id":1,"result":{}}"#,
260            ),
261        ]);
262        let context = TraceContext::new(&events);
263        assert_eq!(context.exchanges().count(), 1);
264    }
265
266    #[test]
267    fn exchanges_for_filters_by_method() {
268        let events = events_of(&[
269            line(
270                0,
271                "client-to-server",
272                r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#,
273            ),
274            line(
275                1,
276                "server-to-client",
277                r#"{"jsonrpc":"2.0","id":1,"result":{}}"#,
278            ),
279            line(
280                2,
281                "client-to-server",
282                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
283            ),
284            line(
285                3,
286                "server-to-client",
287                r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"#,
288            ),
289        ]);
290        let context = TraceContext::new(&events);
291        assert_eq!(context.exchanges_for("tools/list").count(), 1);
292        assert_eq!(context.exchanges_for("ping").count(), 1);
293        assert_eq!(context.exchanges_for("prompts/list").count(), 0);
294    }
295
296    #[test]
297    fn a_response_preceding_its_request_pairs_with_nothing() {
298        // Capture order is the only authority (seq is strictly increasing). A
299        // result at seq 0 cannot answer a request that first appears at seq 1
300        // — you cannot answer before you ask — so it pairs with nothing, and
301        // the exchange-based content checks abstain on it. This is the
302        // deliberate lenient-pairing contract: the orphan response is BASE-004's
303        // to flag, not a content check's; pinned here so it cannot drift into
304        // accidentally pairing across the impossible ordering.
305        let events = events_of(&[
306            line(
307                0,
308                "server-to-client",
309                r#"{"jsonrpc":"2.0","id":5,"result":{"tools":[]}}"#,
310            ),
311            line(
312                1,
313                "client-to-server",
314                r#"{"jsonrpc":"2.0","id":5,"method":"tools/list"}"#,
315            ),
316        ]);
317        let context = TraceContext::new(&events);
318        assert_eq!(
319            context.exchanges().count(),
320            0,
321            "a response cannot pair with a request that appears after it"
322        );
323    }
324
325    #[test]
326    fn server_initiated_requests_pair_with_client_responses() {
327        let events = events_of(&[
328            line(
329                0,
330                "server-to-client",
331                r#"{"jsonrpc":"2.0","id":"s1","method":"sampling/createMessage","params":{}}"#,
332            ),
333            line(
334                1,
335                "client-to-server",
336                r#"{"jsonrpc":"2.0","id":"s1","result":{"role":"assistant"}}"#,
337            ),
338        ]);
339        let context = TraceContext::new(&events);
340        let exchanges: Vec<_> = context.exchanges().collect();
341        assert_eq!(exchanges.len(), 1);
342        assert_eq!(exchanges[0].method, "sampling/createMessage");
343    }
344}