Skip to main content

mcp_trace_validator/
context.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Precomputed per-trace context shared by all checks.
5//!
6//! Checks must be cheap and independent, so anything every check would otherwise
7//! recompute — message classification and the session lifecycle phase at each event —
8//! is derived once here, in a single deterministic pass over the events.
9
10use 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/// The `2025-11-25` session lifecycle phase *before* a given event is processed.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Phase {
22    /// No `initialize` request has been observed yet.
23    BeforeInitialize,
24    /// `initialize` was sent; the server has not yet responded to it.
25    AwaitingInitializeResult,
26    /// The server answered `initialize` with a result; `notifications/initialized`
27    /// has not yet been observed.
28    AfterInitializeSuccess,
29    /// The server answered `initialize` with an error; the session never became ready.
30    AfterInitializeError,
31    /// `notifications/initialized` has been observed; normal operation.
32    Ready,
33}
34
35/// The observed `initialize` exchange, when present.
36#[derive(Debug, Clone, Copy, Default)]
37#[non_exhaustive]
38pub struct InitializeExchange<'a> {
39    /// The `initialize` request: its event `seq` and `params` value (if any).
40    pub request: Option<(u64, Option<&'a Value>)>,
41    /// The successful `initialize` result: its event `seq` and `result` value.
42    pub result: Option<(u64, &'a Value)>,
43    /// The `seq` of the `notifications/initialized` notification.
44    pub initialized: Option<u64>,
45}
46
47/// Everything checks need, precomputed once per trace.
48#[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    /// Builds the context in one pass over the events.
60    ///
61    /// # Panics
62    ///
63    /// When `seq` is not strictly increasing across `events`. The trace
64    /// schema requires it, [`reader::parse_trace`] rejects documents that
65    /// violate it, and several checks compare `seq` values across events on
66    /// the premise that no two events share one — a hand-built slice that
67    /// breaks the premise would otherwise be judged silently wrong, not
68    /// loudly invalid.
69    ///
70    /// [`reader::parse_trace`]: crate::reader::parse_trace
71    #[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    /// The underlying events.
107    #[must_use]
108    pub const fn events(&self) -> &'a [TraceEvent] {
109        self.events
110    }
111
112    /// Iterates `(event, classification, phase-before-event)` triples for message
113    /// events only — the shape almost every check wants.
114    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    /// The observed `initialize` exchange.
123    #[must_use]
124    pub const fn initialize(&self) -> &InitializeExchange<'a> {
125        &self.init
126    }
127
128    /// The server's declared capabilities, from the `initialize` result.
129    #[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    /// The client's declared capabilities, from the `initialize` request params.
137    #[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    /// The lifecycle phase after the entire trace has been processed.
145    #[must_use]
146    pub const fn final_phase(&self) -> Phase {
147        self.final_phase
148    }
149}
150
151/// The `2025-11-25` lifecycle state machine, folded over message events in order.
152struct 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        // Checks compare seq values across events assuming uniqueness (e.g.
237        // session_id_echoed's cutoff); a hand-built slice with duplicates
238        // must fail at the boundary, not be judged silently wrong. The
239        // mutants exclusion for `<` vs `<=` in session_id_echoed rests on
240        // exactly this enforcement.
241        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        // The initialized notification after an error result does not make the
299        // session Ready.
300        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        // happy_path declares empty capability sets on both sides.
319        assert_eq!(context.client_capabilities(), Some(&json!({})));
320        assert_eq!(context.server_capabilities(), Some(&json!({})));
321
322        // A params-less initialize and an answered-by-error exchange expose nothing.
323        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        // Guard pinning: only the response matching the initialize id may transition
334        // the phase; an unrelated result or error must leave it Awaiting.
335        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    /// Property coverage for the lifecycle state machine: arbitrary interleavings of
369    /// a small message alphabet must never break the machine's invariants.
370    mod state_machine_properties {
371        use super::*;
372        use proptest::prelude::*;
373        use serde_json::json;
374
375        /// The alphabet: plausible and implausible protocol moves, both directions.
376        /// Events are built through serde (`TraceEvent` is `#[non_exhaustive]`), which
377        /// is also how every real trace arrives.
378        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        /// Allowed transition edges; anything else is a state-machine defect.
404        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                // Phase-before sequence only walks legal edges, ending at final_phase.
438                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                // Exchange-record implications.
459                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}