1use mcp_conformance_core::message::MessageKind;
14use mcp_conformance_core::trace::TraceEvent;
15use serde_json::Value;
16
17use super::TraceContext;
18
19#[derive(Debug, Clone, Copy)]
21#[non_exhaustive]
22pub struct Exchange<'a> {
23 pub request: &'a TraceEvent,
25 pub method: &'a str,
27 pub params: Option<&'a Value>,
29 pub response: &'a TraceEvent,
31 pub result: Option<&'a Value>,
33}
34
35pub(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 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 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 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 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 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 server_initiated_requests_pair_with_client_responses() {
298 let events = events_of(&[
299 line(
300 0,
301 "server-to-client",
302 r#"{"jsonrpc":"2.0","id":"s1","method":"sampling/createMessage","params":{}}"#,
303 ),
304 line(
305 1,
306 "client-to-server",
307 r#"{"jsonrpc":"2.0","id":"s1","result":{"role":"assistant"}}"#,
308 ),
309 ]);
310 let context = TraceContext::new(&events);
311 let exchanges: Vec<_> = context.exchanges().collect();
312 assert_eq!(exchanges.len(), 1);
313 assert_eq!(exchanges[0].method, "sampling/createMessage");
314 }
315}