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