Skip to main content

mcp_trace_validator/context/
draft.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The stateless `2026-07-28` lifecycle variant (SEP-2575), behind `draft-2026-07-28`.
5//!
6//! The `2026-07-28` draft removes the `initialize`/`initialized` handshake (register 1.3,
7//! 1.5a): a session is **operational from its first message** — there is no
8//! `BeforeInitialize`/`Ready` progression to gate on, which is the defining contrast with
9//! the [`2025-11-25` machine](super::Phase). Each request instead carries its protocol
10//! context (`protocolVersion`, `clientInfo`, `clientCapabilities`) in `_meta`, and the one
11//! handshake-like exchange that remains is the *optional* `server/discover` probe by which
12//! a client may read the server's protocol versions, capabilities, and identity.
13//!
14//! This module models that lifecycle as a second state-machine variant *alongside* — not
15//! replacing — the stateful one ([02-architecture.md](https://github.com/tomtom215/mcp-conformance/blob/main/docs/plan/02-architecture.md)
16//! §Protocol-revision strategy). It is intentionally scoped to the **lifecycle** — the
17//! phase model and the `server/discover` exchange; per-request `_meta` validation, the
18//! removed-method prohibitions (`ping`, `logging/setLevel`,
19//! `notifications/roots/list_changed`), and the `UnsupportedProtocolVersionError` rule are
20//! registry clauses and checks (roadmap M2.5 line 2), which land with the final spec text.
21//!
22//! **Draft-tracking:** the shape here follows the SEPs catalogued in register 1.5a–1.5b
23//! and must be reconciled against the final `2026-07-28` text. That text **shipped on
24//! 2026-07-28** (register 1.5h) with the inventory unchanged, so the reconciliation is
25//! now due rather than pending, and it is roadmap M2.5 line 2's work. The feature gate
26//! keeps this off the default build until the reconciliation lands.
27
28use mcp_conformance_core::message::{MessageKind, classify};
29use mcp_conformance_core::trace::{Direction, TraceEvent};
30use serde_json::Value;
31
32/// The stateless `2026-07-28` lifecycle phase *before* a given event is processed.
33///
34/// There is no handshake to complete, so the steady state is [`Active`](Self::Active)
35/// from the very first event; the only departure is the brief window while a
36/// `server/discover` probe is in flight.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum DraftPhase {
40    /// Operational. Requests may flow immediately — the stateless session has no
41    /// initialize handshake to complete first (SEP-2575).
42    Active,
43    /// A `server/discover` request is in flight; its response has not yet been observed.
44    AwaitingDiscoverResult,
45}
46
47/// The observed `server/discover` exchange — the optional stateless capability/identity
48/// probe — when present. A session is valid with no discovery at all.
49#[derive(Debug, Clone, Copy, Default)]
50#[non_exhaustive]
51pub struct DiscoverExchange<'a> {
52    /// The `server/discover` request: its event `seq` and `params` value (if any).
53    pub request: Option<(u64, Option<&'a Value>)>,
54    /// The successful `server/discover` result: its event `seq` and `result` value.
55    pub result: Option<(u64, &'a Value)>,
56    /// The `seq` of an error response to the `server/discover` request.
57    pub error: Option<u64>,
58}
59
60/// The stateless lifecycle, folded over a trace's message events in order.
61///
62/// ```
63/// use mcp_trace_validator::context::draft::{DraftLifecycle, DraftPhase};
64/// use mcp_conformance_core::trace::TraceEvent;
65///
66/// // A stateless session: the first message is an ordinary request, with no handshake.
67/// let events: Vec<TraceEvent> = serde_json::from_str::<Vec<_>>(r#"[
68///     {"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message",
69///      "payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}
70/// ]"#).unwrap();
71///
72/// let lifecycle = DraftLifecycle::new(&events);
73/// // Operational immediately — no `initialize` required (contrast `2025-11-25`).
74/// assert_eq!(lifecycle.phases()[0], DraftPhase::Active);
75/// assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
76/// assert!(lifecycle.discover().request.is_none());
77/// ```
78#[derive(Debug)]
79pub struct DraftLifecycle<'a> {
80    phases: Vec<DraftPhase>,
81    discover: DiscoverExchange<'a>,
82    final_phase: DraftPhase,
83}
84
85impl<'a> DraftLifecycle<'a> {
86    /// Folds the stateless lifecycle over `events` in one pass, recording the phase
87    /// before each event and the `server/discover` exchange.
88    #[must_use]
89    pub fn new(events: &'a [TraceEvent]) -> Self {
90        let mut phases = Vec::with_capacity(events.len());
91        let mut tracker = DraftTracker::start();
92        for event in events {
93            phases.push(tracker.phase);
94            if let Some(kind) = event.message_payload().map(classify) {
95                tracker.step(event, &kind);
96            }
97        }
98        Self {
99            phases,
100            discover: tracker.discover,
101            final_phase: tracker.phase,
102        }
103    }
104
105    /// The phase *before* each event, in trace order (one entry per event).
106    #[must_use]
107    pub fn phases(&self) -> &[DraftPhase] {
108        &self.phases
109    }
110
111    /// The lifecycle phase after the entire trace has been processed.
112    #[must_use]
113    pub const fn final_phase(&self) -> DraftPhase {
114        self.final_phase
115    }
116
117    /// The observed `server/discover` exchange.
118    #[must_use]
119    pub const fn discover(&self) -> &DiscoverExchange<'a> {
120        &self.discover
121    }
122
123    /// The server's declared capabilities, from the `server/discover` result — the
124    /// stateless analogue of the `initialize` result's capabilities. `None` when no
125    /// discovery completed (the client capability surface lives in each request's `_meta`
126    /// in this revision, which is a per-request concern, not a lifecycle one).
127    #[must_use]
128    pub fn server_capabilities(&self) -> Option<&'a Value> {
129        self.discover
130            .result
131            .and_then(|(_, result)| result.get("capabilities"))
132    }
133}
134
135/// The folding state machine. One-shot discovery: a `server/discover` is recorded only
136/// while no discovery has begun, so the exchange fields are set at most once and the
137/// phase is [`AwaitingDiscoverResult`](DraftPhase::AwaitingDiscoverResult) exactly between
138/// a recorded request and its matching response.
139struct DraftTracker<'a> {
140    phase: DraftPhase,
141    discover: DiscoverExchange<'a>,
142    discover_id: Option<&'a Value>,
143}
144
145impl<'a> DraftTracker<'a> {
146    const fn start() -> Self {
147        Self {
148            phase: DraftPhase::Active,
149            discover: DiscoverExchange {
150                request: None,
151                result: None,
152                error: None,
153            },
154            discover_id: None,
155        }
156    }
157
158    fn step(&mut self, event: &'a TraceEvent, kind: &MessageKind<'a>) {
159        match (self.phase, event.direction, kind) {
160            (
161                DraftPhase::Active,
162                Direction::ClientToServer,
163                MessageKind::Request { method, id },
164            ) if *method == "server/discover" && self.discover.request.is_none() => {
165                self.discover_id = Some(id);
166                self.discover.request = Some((
167                    event.seq,
168                    event
169                        .message_payload()
170                        .and_then(|payload| payload.get("params")),
171                ));
172                self.phase = DraftPhase::AwaitingDiscoverResult;
173            }
174            (
175                DraftPhase::AwaitingDiscoverResult,
176                Direction::ServerToClient,
177                MessageKind::Result { id: Some(id) },
178            ) if Some(*id) == self.discover_id => {
179                self.discover.result = event
180                    .message_payload()
181                    .and_then(|payload| payload.get("result"))
182                    .map(|result| (event.seq, result));
183                self.phase = DraftPhase::Active;
184            }
185            (
186                DraftPhase::AwaitingDiscoverResult,
187                Direction::ServerToClient,
188                MessageKind::Error { id: Some(id), .. },
189            ) if Some(*id) == self.discover_id => {
190                self.discover.error = Some(event.seq);
191                self.phase = DraftPhase::Active;
192            }
193            _ => {}
194        }
195    }
196}
197
198#[cfg(test)]
199#[allow(clippy::unwrap_used)]
200mod tests {
201    use super::*;
202    use crate::reader::{Limits, parse_trace};
203
204    fn events(doc: &str) -> Vec<TraceEvent> {
205        parse_trace(doc, &Limits::default()).unwrap()
206    }
207
208    #[test]
209    fn operational_from_the_first_message_without_a_handshake() {
210        // The defining stateless property: a non-discover request as the very first
211        // message is not gated — the session is Active throughout. (Under `2025-11-25`
212        // this same trace is a LIFE-001 violation.)
213        let trace = events(
214            r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}
215{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}}"#,
216        );
217        let lifecycle = DraftLifecycle::new(&trace);
218        assert_eq!(lifecycle.phases(), [DraftPhase::Active, DraftPhase::Active]);
219        assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
220        assert!(lifecycle.discover().request.is_none());
221        assert_eq!(lifecycle.server_capabilities(), None);
222    }
223
224    #[test]
225    fn discover_request_then_result_records_capabilities_and_returns_to_active() {
226        let trace = events(
227            r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"x":1}}}
228{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"0"}}}}"#,
229        );
230        let lifecycle = DraftLifecycle::new(&trace);
231        // Active before the request, AwaitingDiscoverResult before the response.
232        assert_eq!(
233            lifecycle.phases(),
234            [DraftPhase::Active, DraftPhase::AwaitingDiscoverResult]
235        );
236        // The response returns the session to Active and records the exchange.
237        assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
238        assert_eq!(lifecycle.discover().request.unwrap().0, 0);
239        assert!(lifecycle.discover().request.unwrap().1.is_some());
240        assert_eq!(lifecycle.discover().result.unwrap().0, 1);
241        assert!(lifecycle.discover().error.is_none());
242        assert_eq!(
243            lifecycle.server_capabilities(),
244            Some(&serde_json::json!({"tools": {}}))
245        );
246    }
247
248    #[test]
249    fn discover_error_is_an_error_edge_back_to_active() {
250        let trace = events(
251            r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":7,"method":"server/discover"}}
252{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":7,"error":{"code":-32601,"message":"no discover"}}}"#,
253        );
254        let lifecycle = DraftLifecycle::new(&trace);
255        assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
256        assert_eq!(lifecycle.discover().error, Some(1));
257        assert!(lifecycle.discover().result.is_none());
258        assert_eq!(lifecycle.server_capabilities(), None);
259    }
260
261    #[test]
262    fn a_response_with_an_unrelated_id_does_not_complete_discovery() {
263        // Only the response matching the discover request id may transition back; an
264        // unrelated result or error must leave the session awaiting.
265        for body in [
266            r#"{"jsonrpc":"2.0","id":99,"result":{}}"#,
267            r#"{"jsonrpc":"2.0","id":99,"error":{"code":-32600,"message":"x"}}"#,
268        ] {
269            let response = format!(
270                r#"{{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{body}}}"#
271            );
272            let doc = format!(
273                "{}\n{response}",
274                r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"server/discover"}}"#,
275            );
276            let trace = events(&doc);
277            let lifecycle = DraftLifecycle::new(&trace);
278            assert_eq!(
279                lifecycle.final_phase(),
280                DraftPhase::AwaitingDiscoverResult,
281                "{body}"
282            );
283            assert!(lifecycle.discover().result.is_none(), "{body}");
284            assert!(lifecycle.discover().error.is_none(), "{body}");
285        }
286    }
287
288    #[test]
289    fn removed_handshake_methods_are_not_lifecycle_transitions() {
290        // `initialize` and `notifications/initialized` were removed in the stateless
291        // rework; the lifecycle simply does not act on them (they stay non-events here —
292        // flagging them is a registry/check concern, roadmap M2.5 line 2).
293        let trace = events(
294            r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}}
295{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{}}}
296{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#,
297        );
298        let lifecycle = DraftLifecycle::new(&trace);
299        assert!(lifecycle.phases().iter().all(|p| *p == DraftPhase::Active));
300        assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
301        assert!(lifecycle.discover().request.is_none());
302    }
303
304    #[test]
305    fn empty_trace_is_active_with_no_discovery() {
306        let lifecycle = DraftLifecycle::new(&[]);
307        assert!(lifecycle.phases().is_empty());
308        assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
309        assert!(lifecycle.discover().request.is_none());
310        assert_eq!(lifecycle.server_capabilities(), None);
311    }
312
313    #[test]
314    fn discovery_is_one_shot_a_second_request_while_active_is_ignored() {
315        // After a completed discovery the session is Active; a further `server/discover`
316        // is not re-recorded (the realistic single-probe model, SEP-2575).
317        let trace = events(
318            r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"server/discover"}}
319{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}}
320{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"server/discover"}}"#,
321        );
322        let lifecycle = DraftLifecycle::new(&trace);
323        // The first discovery is the one recorded; the second leaves us Active.
324        assert_eq!(lifecycle.final_phase(), DraftPhase::Active);
325        assert_eq!(lifecycle.discover().request.unwrap().0, 0);
326        assert_eq!(lifecycle.discover().result.unwrap().0, 1);
327    }
328
329    /// Property coverage: arbitrary interleavings of a small message alphabet must never
330    /// break the stateless machine's invariants.
331    mod properties {
332        use super::*;
333        use proptest::prelude::*;
334        use serde_json::json;
335
336        fn arbitrary_event(seq: u64, choice: u8, direction_bit: bool) -> TraceEvent {
337            let payload = match choice % 6 {
338                0 => json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}),
339                1 => json!({"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}),
340                2 => json!({"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"x"}}),
341                3 => json!({"jsonrpc":"2.0","id":99,"result":{}}),
342                4 => json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
343                _ => json!({"jsonrpc":"2.0","method":"notifications/cancelled"}),
344            };
345            let direction = if direction_bit {
346                "client-to-server"
347            } else {
348                "server-to-client"
349            };
350            serde_json::from_value(json!({
351                "seq": seq,
352                "direction": direction,
353                "transport": "stdio",
354                "kind": "message",
355                "payload": payload,
356            }))
357            .unwrap()
358        }
359
360        proptest! {
361            #[test]
362            fn invariants_hold_for_arbitrary_sequences(
363                moves in proptest::collection::vec((any::<u8>(), any::<bool>()), 0..32)
364            ) {
365                let events: Vec<TraceEvent> = moves
366                    .iter()
367                    .enumerate()
368                    .map(|(index, (choice, direction))| {
369                        arbitrary_event(index as u64, *choice, *direction)
370                    })
371                    .collect();
372                let lifecycle = DraftLifecycle::new(&events);
373
374                // One phase-before per event, and a stateless session starts Active.
375                prop_assert_eq!(lifecycle.phases().len(), events.len());
376                if let Some(first) = lifecycle.phases().first() {
377                    prop_assert_eq!(*first, DraftPhase::Active);
378                }
379
380                let discover = lifecycle.discover();
381                // Awaiting iff a discovery was requested whose response has not arrived.
382                let outstanding =
383                    discover.request.is_some() && discover.result.is_none() && discover.error.is_none();
384                prop_assert_eq!(lifecycle.final_phase() == DraftPhase::AwaitingDiscoverResult, outstanding);
385
386                // A response is only ever recorded against a request, and never both.
387                if discover.result.is_some() || discover.error.is_some() {
388                    prop_assert!(discover.request.is_some());
389                }
390                prop_assert!(!(discover.result.is_some() && discover.error.is_some()));
391
392                // Entering AwaitingDiscoverResult requires a client `server/discover`
393                // request at that step — the transition is never spurious.
394                for (index, pair) in lifecycle.phases().windows(2).enumerate() {
395                    if pair[0] == DraftPhase::Active && pair[1] == DraftPhase::AwaitingDiscoverResult {
396                        let event = &events[index];
397                        prop_assert_eq!(event.direction, Direction::ClientToServer);
398                        let kind = event.message_payload().map(classify);
399                        let is_discover_request = matches!(
400                            kind,
401                            Some(MessageKind::Request { method, .. }) if method == "server/discover"
402                        );
403                        prop_assert!(is_discover_request);
404                    }
405                }
406            }
407        }
408    }
409}