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