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
16#[cfg(feature = "draft-2026-07-28")]
17pub mod draft;
18
19pub use pairing::Exchange;
20
21/// The `2025-11-25` session lifecycle phase *before* a given event is processed.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum Phase {
25    /// No `initialize` request has been observed yet.
26    BeforeInitialize,
27    /// `initialize` was sent; the server has not yet responded to it.
28    AwaitingInitializeResult,
29    /// The server answered `initialize` with a result; `notifications/initialized`
30    /// has not yet been observed.
31    AfterInitializeSuccess,
32    /// The server answered `initialize` with an error; the session never became ready.
33    AfterInitializeError,
34    /// `notifications/initialized` has been observed; normal operation.
35    Ready,
36}
37
38/// The observed `initialize` exchange, when present.
39#[derive(Debug, Clone, Copy, Default)]
40#[non_exhaustive]
41pub struct InitializeExchange<'a> {
42    /// The `initialize` request: its event `seq` and `params` value (if any).
43    pub request: Option<(u64, Option<&'a Value>)>,
44    /// The successful `initialize` result: its event `seq` and `result` value.
45    pub result: Option<(u64, &'a Value)>,
46    /// The `seq` of the `notifications/initialized` notification.
47    pub initialized: Option<u64>,
48}
49
50/// Everything checks need, precomputed once per trace.
51#[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    /// Builds the context in one pass over the events.
63    ///
64    /// # Panics
65    ///
66    /// When `seq` is not strictly increasing across `events`. The trace
67    /// schema requires it, [`reader::parse_trace`] rejects documents that
68    /// violate it, and several checks compare `seq` values across events on
69    /// the premise that no two events share one — a hand-built slice that
70    /// breaks the premise would otherwise be judged silently wrong, not
71    /// loudly invalid.
72    ///
73    /// [`reader::parse_trace`]: crate::reader::parse_trace
74    #[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    /// The underlying events.
110    #[must_use]
111    pub const fn events(&self) -> &'a [TraceEvent] {
112        self.events
113    }
114
115    /// Iterates `(event, classification, phase-before-event)` triples for message
116    /// events only — the shape almost every check wants.
117    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    /// The observed `initialize` exchange.
126    #[must_use]
127    pub const fn initialize(&self) -> &InitializeExchange<'a> {
128        &self.init
129    }
130
131    /// The server's declared capabilities, from the `initialize` result.
132    #[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    /// The client's declared capabilities, from the `initialize` request params.
140    #[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    /// The lifecycle phase after the entire trace has been processed.
148    #[must_use]
149    pub const fn final_phase(&self) -> Phase {
150        self.final_phase
151    }
152}
153
154/// The `2025-11-25` lifecycle state machine, folded over message events in order.
155struct 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        // Checks compare seq values across events assuming uniqueness (e.g.
240        // session_id_echoed's cutoff); a hand-built slice with duplicates
241        // must fail at the boundary, not be judged silently wrong. The
242        // mutants exclusion for `<` vs `<=` in session_id_echoed rests on
243        // exactly this enforcement.
244        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        // The initialized notification after an error result does not make the
302        // session Ready.
303        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        // happy_path declares empty capability sets on both sides.
322        assert_eq!(context.client_capabilities(), Some(&json!({})));
323        assert_eq!(context.server_capabilities(), Some(&json!({})));
324
325        // A params-less initialize and an answered-by-error exchange expose nothing.
326        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        // Guard pinning: only the response matching the initialize id may transition
337        // the phase; an unrelated result or error must leave it Awaiting.
338        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    /// Property coverage for the lifecycle state machine: arbitrary interleavings of
372    /// a small message alphabet must never break the machine's invariants.
373    mod state_machine_properties {
374        use super::*;
375        use proptest::prelude::*;
376        use serde_json::json;
377
378        /// The alphabet: plausible and implausible protocol moves, both directions.
379        /// Events are built through serde (`TraceEvent` is `#[non_exhaustive]`), which
380        /// is also how every real trace arrives.
381        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        /// Allowed transition edges; anything else is a state-machine defect.
407        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                // Phase-before sequence only walks legal edges, ending at final_phase.
441                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                // Exchange-record implications.
462                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}