1use mcp_conformance_core::message::{MessageKind, classify};
27use mcp_conformance_core::trace::{Direction, TraceEvent};
28use serde_json::Value;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum DraftPhase {
38 Active,
41 AwaitingDiscoverResult,
43}
44
45#[derive(Debug, Clone, Copy, Default)]
48#[non_exhaustive]
49pub struct DiscoverExchange<'a> {
50 pub request: Option<(u64, Option<&'a Value>)>,
52 pub result: Option<(u64, &'a Value)>,
54 pub error: Option<u64>,
56}
57
58#[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 #[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 #[must_use]
105 pub fn phases(&self) -> &[DraftPhase] {
106 &self.phases
107 }
108
109 #[must_use]
111 pub const fn final_phase(&self) -> DraftPhase {
112 self.final_phase
113 }
114
115 #[must_use]
117 pub const fn discover(&self) -> &DiscoverExchange<'a> {
118 &self.discover
119 }
120
121 #[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
133struct 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 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 assert_eq!(
231 lifecycle.phases(),
232 [DraftPhase::Active, DraftPhase::AwaitingDiscoverResult]
233 );
234 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 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 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 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 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 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 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 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 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 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}