1use mcp_conformance_core::message::{MessageKind, classify};
11use mcp_conformance_core::trace::{Direction, TraceEvent};
12use serde_json::Value;
13
14mod pairing;
15
16pub use pairing::Exchange;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Phase {
22 BeforeInitialize,
24 AwaitingInitializeResult,
26 AfterInitializeSuccess,
29 AfterInitializeError,
31 Ready,
33}
34
35#[derive(Debug, Clone, Copy, Default)]
37#[non_exhaustive]
38pub struct InitializeExchange<'a> {
39 pub request: Option<(u64, Option<&'a Value>)>,
41 pub result: Option<(u64, &'a Value)>,
43 pub initialized: Option<u64>,
45}
46
47#[derive(Debug)]
49pub struct TraceContext<'a> {
50 events: &'a [TraceEvent],
51 kinds: Vec<Option<MessageKind<'a>>>,
52 phases: Vec<Phase>,
53 pairs: Vec<Option<usize>>,
54 init: InitializeExchange<'a>,
55 final_phase: Phase,
56}
57
58impl<'a> TraceContext<'a> {
59 #[must_use]
72 pub fn new(events: &'a [TraceEvent]) -> Self {
73 if let Some(window) = events.windows(2).find(|w| w[0].seq >= w[1].seq) {
74 panic!(
75 "trace events must have strictly increasing seq (the reader guarantees \
76 this; hand-built slices must too): seq {} is followed by seq {}",
77 window[0].seq, window[1].seq
78 );
79 }
80 let kinds: Vec<Option<MessageKind<'a>>> = events
81 .iter()
82 .map(|event| event.message_payload().map(classify))
83 .collect();
84
85 let mut phases = Vec::with_capacity(events.len());
86 let mut tracker = LifecycleTracker::start();
87 for (event, kind) in events.iter().zip(&kinds) {
88 phases.push(tracker.phase);
89 if let Some(kind) = kind {
90 tracker.step(event, kind);
91 }
92 }
93
94 let pairs = pairing::pair_responses(events, &kinds);
95
96 Self {
97 events,
98 kinds,
99 phases,
100 pairs,
101 init: tracker.init,
102 final_phase: tracker.phase,
103 }
104 }
105
106 #[must_use]
108 pub const fn events(&self) -> &'a [TraceEvent] {
109 self.events
110 }
111
112 pub fn messages(&self) -> impl Iterator<Item = (&'a TraceEvent, &MessageKind<'a>, Phase)> + '_ {
115 self.events
116 .iter()
117 .zip(&self.kinds)
118 .zip(&self.phases)
119 .filter_map(|((event, kind), phase)| kind.as_ref().map(|kind| (event, kind, *phase)))
120 }
121
122 #[must_use]
124 pub const fn initialize(&self) -> &InitializeExchange<'a> {
125 &self.init
126 }
127
128 #[must_use]
130 pub fn server_capabilities(&self) -> Option<&'a Value> {
131 self.init
132 .result
133 .and_then(|(_, result)| result.get("capabilities"))
134 }
135
136 #[must_use]
138 pub fn client_capabilities(&self) -> Option<&'a Value> {
139 self.init
140 .request
141 .and_then(|(_, params)| params?.get("capabilities"))
142 }
143
144 #[must_use]
146 pub const fn final_phase(&self) -> Phase {
147 self.final_phase
148 }
149}
150
151struct LifecycleTracker<'a> {
153 phase: Phase,
154 init: InitializeExchange<'a>,
155 initialize_id: Option<&'a Value>,
156}
157
158impl<'a> LifecycleTracker<'a> {
159 const fn start() -> Self {
160 Self {
161 phase: Phase::BeforeInitialize,
162 init: InitializeExchange {
163 request: None,
164 result: None,
165 initialized: None,
166 },
167 initialize_id: None,
168 }
169 }
170
171 fn step(&mut self, event: &'a TraceEvent, kind: &MessageKind<'a>) {
172 match (self.phase, event.direction, kind) {
173 (
174 Phase::BeforeInitialize,
175 Direction::ClientToServer,
176 MessageKind::Request { method, id },
177 ) if *method == "initialize" => {
178 self.initialize_id = Some(id);
179 self.init.request = Some((
180 event.seq,
181 event
182 .message_payload()
183 .and_then(|payload| payload.get("params")),
184 ));
185 self.phase = Phase::AwaitingInitializeResult;
186 }
187 (
188 Phase::AwaitingInitializeResult,
189 Direction::ServerToClient,
190 MessageKind::Result { id: Some(id) },
191 ) if Some(*id) == self.initialize_id => {
192 self.init.result = event
193 .message_payload()
194 .and_then(|payload| payload.get("result"))
195 .map(|result| (event.seq, result));
196 self.phase = Phase::AfterInitializeSuccess;
197 }
198 (
199 Phase::AwaitingInitializeResult,
200 Direction::ServerToClient,
201 MessageKind::Error { id: Some(id), .. },
202 ) if Some(*id) == self.initialize_id => {
203 self.phase = Phase::AfterInitializeError;
204 }
205 (
206 Phase::AfterInitializeSuccess,
207 Direction::ClientToServer,
208 MessageKind::Notification { method },
209 ) if *method == "notifications/initialized" => {
210 self.init.initialized = Some(event.seq);
211 self.phase = Phase::Ready;
212 }
213 _ => {}
214 }
215 }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used)]
220mod tests {
221 use super::*;
222 use crate::reader::{Limits, parse_trace};
223
224 fn happy_path() -> Vec<TraceEvent> {
225 let doc = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}
226{"seq":1,"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"}}}}
227{"seq":2,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}
228{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}
229{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#;
230 parse_trace(doc, &Limits::default()).unwrap()
231 }
232
233 #[test]
234 #[should_panic(expected = "strictly increasing seq")]
235 fn duplicate_seq_is_a_loud_contract_violation() {
236 use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
242 let duplicate = vec![
243 TraceEvent::new(
244 7,
245 Direction::ClientToServer,
246 TransportKind::Stdio,
247 EventBody::Message {
248 payload: serde_json::json!({"jsonrpc":"2.0","id":1,"method":"ping"}),
249 },
250 ),
251 TraceEvent::new(
252 7,
253 Direction::ServerToClient,
254 TransportKind::Stdio,
255 EventBody::Message {
256 payload: serde_json::json!({"jsonrpc":"2.0","id":1,"result":{}}),
257 },
258 ),
259 ];
260 let _ = TraceContext::new(&duplicate);
261 }
262
263 #[test]
264 fn tracks_phases_through_initialization() {
265 let events = happy_path();
266 let context = TraceContext::new(&events);
267 let phases: Vec<Phase> = context.messages().map(|(_, _, phase)| phase).collect();
268 assert_eq!(
269 phases,
270 vec![
271 Phase::BeforeInitialize,
272 Phase::AwaitingInitializeResult,
273 Phase::AfterInitializeSuccess,
274 Phase::Ready,
275 ]
276 );
277 assert_eq!(context.final_phase(), Phase::Ready);
278 }
279
280 #[test]
281 fn records_initialize_exchange() {
282 let events = happy_path();
283 let context = TraceContext::new(&events);
284 let init = context.initialize();
285 assert_eq!(init.request.unwrap().0, 1);
286 assert!(init.request.unwrap().1.is_some());
287 assert_eq!(init.result.unwrap().0, 2);
288 assert_eq!(init.initialized, Some(3));
289 }
290
291 #[test]
292 fn initialize_error_blocks_ready() {
293 let doc = r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}}
294{"seq":2,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Unsupported protocol version"}}}
295{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
296 let events = parse_trace(doc, &Limits::default()).unwrap();
297 let context = TraceContext::new(&events);
298 assert_eq!(context.initialize().initialized, None);
301 assert_eq!(context.final_phase(), Phase::AfterInitializeError);
302 }
303
304 #[test]
305 fn empty_trace_has_no_exchange() {
306 let context = TraceContext::new(&[]);
307 assert!(context.initialize().request.is_none());
308 assert_eq!(context.final_phase(), Phase::BeforeInitialize);
309 assert_eq!(context.server_capabilities(), None);
310 assert_eq!(context.client_capabilities(), None);
311 }
312
313 #[test]
314 fn capability_accessors_read_their_declaration_surfaces() {
315 use serde_json::json;
316 let events = happy_path();
317 let context = TraceContext::new(&events);
318 assert_eq!(context.client_capabilities(), Some(&json!({})));
320 assert_eq!(context.server_capabilities(), Some(&json!({})));
321
322 let doc = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize"}}
324{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"x"}}}"#;
325 let events = parse_trace(doc, &Limits::default()).unwrap();
326 let context = TraceContext::new(&events);
327 assert_eq!(context.client_capabilities(), None);
328 assert_eq!(context.server_capabilities(), None);
329 }
330
331 #[test]
332 fn responses_with_unrelated_ids_do_not_complete_initialization() {
333 for body in [
336 r#"{"jsonrpc":"2.0","id":99,"result":{}}"#,
337 r#"{"jsonrpc":"2.0","id":99,"error":{"code":-32600,"message":"x"}}"#,
338 ] {
339 let response = format!(
340 r#"{{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{body}}}"#
341 );
342 let doc = format!(
343 "{}\n{response}",
344 r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}}"#,
345 );
346 let events = parse_trace(&doc, &Limits::default()).unwrap();
347 let context = TraceContext::new(&events);
348 assert!(context.initialize().result.is_none(), "{body}");
349 assert_eq!(
350 context.final_phase(),
351 Phase::AwaitingInitializeResult,
352 "{body}"
353 );
354 }
355 }
356
357 #[test]
358 fn only_the_initialized_notification_makes_the_session_ready() {
359 let doc = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}}
360{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{}}}
361{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/cancelled"}}"#;
362 let events = parse_trace(doc, &Limits::default()).unwrap();
363 let context = TraceContext::new(&events);
364 assert_eq!(context.initialize().initialized, None);
365 assert_eq!(context.final_phase(), Phase::AfterInitializeSuccess);
366 }
367
368 mod state_machine_properties {
371 use super::*;
372 use proptest::prelude::*;
373 use serde_json::json;
374
375 fn arbitrary_event(seq: u64, choice: u8, direction_bit: bool) -> TraceEvent {
379 let payload = match choice % 7 {
380 0 => json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}),
381 1 => json!({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25"}}),
382 2 => json!({"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"x"}}),
383 3 => json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
384 4 => json!({"jsonrpc":"2.0","id":99,"result":{}}),
385 5 => json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
386 _ => json!({"jsonrpc":"2.0","method":"notifications/cancelled"}),
387 };
388 let direction = if direction_bit {
389 "client-to-server"
390 } else {
391 "server-to-client"
392 };
393 serde_json::from_value(json!({
394 "seq": seq,
395 "direction": direction,
396 "transport": "stdio",
397 "kind": "message",
398 "payload": payload,
399 }))
400 .unwrap()
401 }
402
403 const fn edge_is_legal(from: Phase, to: Phase) -> bool {
405 matches!(
406 (from, to),
407 (
408 Phase::BeforeInitialize,
409 Phase::BeforeInitialize | Phase::AwaitingInitializeResult
410 ) | (
411 Phase::AwaitingInitializeResult,
412 Phase::AwaitingInitializeResult
413 | Phase::AfterInitializeSuccess
414 | Phase::AfterInitializeError
415 ) | (
416 Phase::AfterInitializeSuccess,
417 Phase::AfterInitializeSuccess | Phase::Ready
418 ) | (Phase::AfterInitializeError, Phase::AfterInitializeError)
419 | (Phase::Ready, Phase::Ready)
420 )
421 }
422
423 proptest! {
424 #[test]
425 fn invariants_hold_for_arbitrary_sequences(
426 moves in proptest::collection::vec((any::<u8>(), any::<bool>()), 0..32)
427 ) {
428 let events: Vec<TraceEvent> = moves
429 .iter()
430 .enumerate()
431 .map(|(index, (choice, direction))| {
432 arbitrary_event(index as u64, *choice, *direction)
433 })
434 .collect();
435 let context = TraceContext::new(&events);
436
437 let phases: Vec<Phase> =
439 context.messages().map(|(_, _, phase)| phase).collect();
440 prop_assert_eq!(phases.len(), events.len());
441 for window in phases.windows(2) {
442 prop_assert!(
443 edge_is_legal(window[0], window[1]),
444 "illegal edge {:?} -> {:?}",
445 window[0],
446 window[1]
447 );
448 }
449 if let Some(last) = phases.last() {
450 prop_assert!(
451 edge_is_legal(*last, context.final_phase()),
452 "illegal final edge {:?} -> {:?}",
453 last,
454 context.final_phase()
455 );
456 }
457
458 let init = context.initialize();
460 if init.result.is_some() || init.initialized.is_some() {
461 prop_assert!(init.request.is_some());
462 }
463 if init.initialized.is_some() {
464 prop_assert!(init.result.is_some());
465 prop_assert_eq!(context.final_phase(), Phase::Ready);
466 }
467 if context.final_phase() == Phase::Ready {
468 prop_assert!(init.initialized.is_some());
469 }
470 }
471 }
472 }
473}