Skip to main content

mcp_trace_validator/checks/
lifecycle.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the `2025-11-25` session lifecycle requirements (`LIFE-*`).
5//!
6//! These lean on [`TraceContext`]'s precomputed phases: every check sees the lifecycle
7//! phase *before* each event, which is exactly the state the spec's ordering rules are
8//! written against.
9
10use mcp_conformance_core::message::MessageKind;
11use mcp_conformance_core::revision::ProtocolRevision;
12use mcp_conformance_core::trace::Direction;
13use serde_json::Value;
14
15use super::FindingSink;
16use crate::context::{Phase, TraceContext};
17
18/// `LIFE-001`: "The initialization phase MUST be the first interaction between client
19/// and server." — the first message in the trace must be the client's `initialize`
20/// request. A trace with no messages examines nothing and reports *not observed*
21/// (ADR-0012); the CLI declines such a trace outright, because it is a capture
22/// that failed rather than a session that conformed.
23pub(super) fn first_interaction_initialize(context: &TraceContext<'_>, sink: &mut FindingSink) {
24    let Some((event, kind, _)) = context.messages().next() else {
25        return;
26    };
27    sink.examined();
28    match (event.direction, kind) {
29        (Direction::ClientToServer, MessageKind::Request { method, .. })
30            if *method == "initialize" => {}
31        (Direction::ClientToServer, MessageKind::Request { method, .. }) => sink.push(
32            Some(event.seq),
33            format!("first message is a {method:?} request, expected \"initialize\""),
34        ),
35        (direction, _) => sink.push(
36            Some(event.seq),
37            format!(
38                "first message is {} ({}), expected the client's \"initialize\" request",
39                describe_kind(kind),
40                direction_name(direction)
41            ),
42        ),
43    }
44}
45
46/// `LIFE-002`: the `initialize` request must carry `protocolVersion`, `capabilities`,
47/// and `clientInfo` params.
48pub(super) fn initialize_params(context: &TraceContext<'_>, sink: &mut FindingSink) {
49    let Some((seq, params)) = context.initialize().request else {
50        return; // No initialize at all: LIFE-001's finding.
51    };
52    sink.examined();
53    let Some(params) = params else {
54        sink.push(
55            Some(seq),
56            "initialize request has no params; protocolVersion, capabilities, and clientInfo are required".to_owned(),
57        );
58        return;
59    };
60    expect_member(
61        sink,
62        seq,
63        params,
64        "protocolVersion",
65        Value::is_string,
66        "a string",
67    );
68    expect_member(
69        sink,
70        seq,
71        params,
72        "capabilities",
73        Value::is_object,
74        "an object",
75    );
76    expect_member(
77        sink,
78        seq,
79        params,
80        "clientInfo",
81        Value::is_object,
82        "an object",
83    );
84}
85
86fn expect_member(
87    sink: &mut FindingSink,
88    seq: u64,
89    params: &Value,
90    member: &str,
91    predicate: fn(&Value) -> bool,
92    expected: &str,
93) {
94    match params.get(member) {
95        None => sink.push(
96            Some(seq),
97            format!("initialize params lack the {member} member"),
98        ),
99        Some(value) if !predicate(value) => sink.push(
100            Some(seq),
101            format!("initialize params member {member} should be {expected}"),
102        ),
103        Some(_) => {}
104    }
105}
106
107/// `LIFE-003`: "After successful initialization, the client MUST send an `initialized`
108/// notification …".
109pub(super) fn initialized_notification(context: &TraceContext<'_>, sink: &mut FindingSink) {
110    let init = context.initialize();
111    let Some((result_seq, _)) = init.result else {
112        return; // Nothing answered initialize, so nothing owes a follow-up.
113    };
114    sink.examined();
115    if init.initialized.is_none() {
116        sink.push(
117            Some(result_seq),
118            "the server answered initialize here, but no notifications/initialized notification follows in the trace".to_owned(),
119        );
120    }
121}
122
123/// `LIFE-004`: "The client SHOULD NOT send requests other than pings before the server
124/// has responded to the `initialize` request."
125pub(super) fn client_requests_before_init_response(
126    context: &TraceContext<'_>,
127    sink: &mut FindingSink,
128) {
129    for (event, kind, phase) in context.messages() {
130        if event.direction != Direction::ClientToServer {
131            continue;
132        }
133        if !matches!(
134            phase,
135            Phase::BeforeInitialize | Phase::AwaitingInitializeResult
136        ) {
137            continue;
138        }
139        // The subject is the window, not the request. This clause forbids a
140        // request *existing* here, so sending none through a window the trace
141        // actually shows is observable compliance — counting only requests
142        // would report the compliant case as unjudged.
143        sink.examined();
144        let MessageKind::Request { method, .. } = kind else {
145            continue;
146        };
147        if *method != "initialize" && *method != "ping" {
148            sink.push(
149                Some(event.seq),
150                format!(
151                    "client sent a {method:?} request before the server responded to initialize"
152                ),
153            );
154        }
155    }
156}
157
158/// `LIFE-005`: "The server SHOULD NOT send requests other than pings and logging before
159/// receiving the `initialized` notification."
160///
161/// In `2025-11-25`, logging travels as `notifications/message` — a notification, which
162/// this requests-only check never flags — so the spec's "and logging" allowance needs
163/// no special case here.
164pub(super) fn server_requests_before_initialized(
165    context: &TraceContext<'_>,
166    sink: &mut FindingSink,
167) {
168    for (event, kind, phase) in context.messages() {
169        if event.direction != Direction::ServerToClient || phase == Phase::Ready {
170            continue;
171        }
172        // The window is the subject, as in `LIFE-004` above: a server that
173        // answered the handshake and asked for nothing has complied where the
174        // trace could have shown otherwise.
175        sink.examined();
176        let MessageKind::Request { method, .. } = kind else {
177            continue;
178        };
179        if *method != "ping" {
180            sink.push(
181                Some(event.seq),
182                format!(
183                    "server sent a {method:?} request before receiving the initialized notification"
184                ),
185            );
186        }
187    }
188}
189
190/// `LIFE-007`: "In the `initialize` request, the client MUST send a protocol version
191/// it supports." — presence and string-ness of the version is the wire-observable
192/// core; whether the client truly *supports* the version it sent is not in the trace.
193pub(super) fn initialize_protocol_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
194    let Some((seq, params)) = context.initialize().request else {
195        return; // No initialize at all: LIFE-001's finding.
196    };
197    sink.examined();
198    match params.and_then(|params| params.get("protocolVersion")) {
199        None => sink.push(
200            Some(seq),
201            "initialize request sends no protocolVersion".to_owned(),
202        ),
203        Some(Value::String(_)) => {}
204        Some(other) => sink.push(
205            Some(seq),
206            format!("initialize request protocolVersion is {other}, expected a version string"),
207        ),
208    }
209}
210
211/// `LIFE-006`: the server's `initialize` result must carry a `protocolVersion` that is
212/// a dated revision identifier. Whether the *negotiation* (same-version-if-supported)
213/// was honored is not judgeable from a single trace; the shape and format are.
214pub(super) fn initialize_result_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
215    let Some((seq, result)) = context.initialize().result else {
216        return;
217    };
218    sink.examined();
219    match result.get("protocolVersion") {
220        None => sink.push(
221            Some(seq),
222            "initialize result lacks the protocolVersion member".to_owned(),
223        ),
224        Some(Value::String(version)) => {
225            if version.parse::<ProtocolRevision>().is_err() {
226                sink.push(
227                    Some(seq),
228                    format!(
229                        "initialize result protocolVersion {version:?} is not a dated revision identifier (YYYY-MM-DD)"
230                    ),
231                );
232            }
233        }
234        Some(other) => sink.push(
235            Some(seq),
236            format!("initialize result protocolVersion is {other}, expected a revision string"),
237        ),
238    }
239}
240
241/// `LIFE-010`: the initialize result must carry the server's capabilities and
242/// implementation information (`capabilities` and `serverInfo` objects).
243///
244/// A missing or error-answered initialize exchange is owned by the handshake
245/// checks (LIFE-001/003/006); this one judges only a result that exists.
246pub(super) fn initialize_result_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
247    let Some((seq, result)) = context.initialize().result else {
248        return;
249    };
250    sink.examined();
251    for (member, label) in [
252        ("capabilities", "its capabilities"),
253        ("serverInfo", "its implementation information (serverInfo)"),
254    ] {
255        match result.get(member) {
256            None => sink.push(
257                Some(seq),
258                format!("initialize result lacks {label}: no {member} member"),
259            ),
260            Some(value) if !value.is_object() => sink.push(
261                Some(seq),
262                format!("initialize result {member} is {value}, expected an object"),
263            ),
264            Some(_) => {}
265        }
266    }
267}
268
269const fn describe_kind(kind: &MessageKind<'_>) -> &'static str {
270    match kind {
271        MessageKind::Request { .. } => "a request",
272        MessageKind::Notification { .. } => "a notification",
273        MessageKind::Result { .. } => "a result response",
274        MessageKind::Error { .. } => "an error response",
275        MessageKind::Invalid { .. } => "not a valid JSON-RPC message",
276        // MessageKind is #[non_exhaustive]; future shapes still deserve a description.
277        _ => "an unrecognized message kind",
278    }
279}
280
281const fn direction_name(direction: Direction) -> &'static str {
282    match direction {
283        Direction::ClientToServer => "client to server",
284        Direction::ServerToClient => "server to client",
285    }
286}
287
288#[cfg(test)]
289#[allow(clippy::unwrap_used, clippy::expect_used)]
290mod tests {
291    use super::*;
292    use serde_json::json;
293
294    #[test]
295    fn describe_kind_names_every_shape_exactly() {
296        // These strings appear verbatim in findings; mutating any arm must fail here.
297        let request = json!({"id": 1, "method": "x"});
298        let notification = json!({"method": "x"});
299        let result = json!({"id": 1, "result": {}});
300        let error = json!({"id": 1, "error": {}});
301        let invalid = json!([]);
302        let cases = [
303            (&request, "a request"),
304            (&notification, "a notification"),
305            (&result, "a result response"),
306            (&error, "an error response"),
307            (&invalid, "not a valid JSON-RPC message"),
308        ];
309        for (payload, expected) in cases {
310            let kind = mcp_conformance_core::message::classify(payload);
311            assert_eq!(describe_kind(&kind), expected, "for {payload}");
312        }
313    }
314
315    #[test]
316    fn direction_name_is_exact() {
317        assert_eq!(
318            direction_name(Direction::ClientToServer),
319            "client to server"
320        );
321        assert_eq!(
322            direction_name(Direction::ServerToClient),
323            "server to client"
324        );
325    }
326
327    #[test]
328    fn initialize_params_with_wrong_types_are_flagged() {
329        // Present-but-mistyped members must be findings, not silent passes — this
330        // pins the type-predicate guard in expect_member.
331        use crate::context::TraceContext;
332        use crate::reader::{Limits, parse_trace};
333        let doc = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":123,"capabilities":[],"clientInfo":"nope"}}}"#;
334        let events = parse_trace(doc, &Limits::default()).expect("valid trace");
335        let context = TraceContext::new(&events);
336        let findings = crate::checks::find("lifecycle.initialize-params")
337            .expect("check exists")
338            .run(&context)
339            .findings;
340        assert_eq!(findings.len(), 3, "{findings:?}");
341        assert!(
342            findings[0]
343                .detail
344                .contains("protocolVersion should be a string")
345        );
346        assert!(
347            findings[1]
348                .detail
349                .contains("capabilities should be an object")
350        );
351        assert!(
352            findings[2]
353                .detail
354                .contains("clientInfo should be an object")
355        );
356    }
357
358    #[test]
359    fn initialize_result_shape_demands_capability_and_serverinfo_objects() {
360        fn handshake_with_result(result: &str) -> String {
361            let request = r#"{"seq":0,"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"}}}}"#;
362            format!(
363                "{request}\n{{\"seq\":1,\"direction\":\"server-to-client\",\"transport\":\"stdio\",\"kind\":\"message\",\"payload\":{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{result}}}}}"
364            )
365        }
366        let run = |result: &str| {
367            let trace = handshake_with_result(result);
368            let events = crate::reader::parse_trace(&trace, &crate::reader::Limits::default())
369                .expect("trace parses");
370            let context = TraceContext::new(&events);
371            crate::checks::find("lifecycle.initialize-result-shape")
372                .expect("check registered")
373                .run(&context)
374                .findings
375        };
376
377        // Complete shape: no findings.
378        assert!(
379            run(r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}"#)
380                .is_empty()
381        );
382        // Missing capabilities only.
383        let missing_caps =
384            run(r#"{"protocolVersion":"2025-11-25","serverInfo":{"name":"s","version":"0"}}"#);
385        assert_eq!(missing_caps.len(), 1, "{missing_caps:?}");
386        assert!(missing_caps[0].detail.contains("capabilities"));
387        // Missing serverInfo only.
388        let missing_info = run(r#"{"protocolVersion":"2025-11-25","capabilities":{}}"#);
389        assert_eq!(missing_info.len(), 1, "{missing_info:?}");
390        assert!(missing_info[0].detail.contains("serverInfo"));
391        // Wrong types are findings too, one per member.
392        let wrong = run(r#"{"capabilities":7,"serverInfo":"s"}"#);
393        assert_eq!(wrong.len(), 2, "{wrong:?}");
394        // No initialize result at all: the handshake checks own that case.
395        let events = crate::reader::parse_trace("", &crate::reader::Limits::default()).unwrap();
396        let context = TraceContext::new(&events);
397        assert!(
398            crate::checks::find("lifecycle.initialize-result-shape")
399                .unwrap()
400                .run(&context)
401                .findings
402                .is_empty()
403        );
404    }
405}