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. An empty trace passes vacuously.
21pub(super) fn first_interaction_initialize(context: &TraceContext<'_>, sink: &mut FindingSink) {
22    let Some((event, kind, _)) = context.messages().next() else {
23        return;
24    };
25    match (event.direction, kind) {
26        (Direction::ClientToServer, MessageKind::Request { method, .. })
27            if *method == "initialize" => {}
28        (Direction::ClientToServer, MessageKind::Request { method, .. }) => sink.push(
29            Some(event.seq),
30            format!("first message is a {method:?} request, expected \"initialize\""),
31        ),
32        (direction, _) => sink.push(
33            Some(event.seq),
34            format!(
35                "first message is {} ({}), expected the client's \"initialize\" request",
36                describe_kind(kind),
37                direction_name(direction)
38            ),
39        ),
40    }
41}
42
43/// `LIFE-002`: the `initialize` request must carry `protocolVersion`, `capabilities`,
44/// and `clientInfo` params.
45pub(super) fn initialize_params(context: &TraceContext<'_>, sink: &mut FindingSink) {
46    let Some((seq, params)) = context.initialize().request else {
47        return; // No initialize at all: LIFE-001's finding.
48    };
49    let Some(params) = params else {
50        sink.push(
51            Some(seq),
52            "initialize request has no params; protocolVersion, capabilities, and clientInfo are required".to_owned(),
53        );
54        return;
55    };
56    expect_member(
57        sink,
58        seq,
59        params,
60        "protocolVersion",
61        Value::is_string,
62        "a string",
63    );
64    expect_member(
65        sink,
66        seq,
67        params,
68        "capabilities",
69        Value::is_object,
70        "an object",
71    );
72    expect_member(
73        sink,
74        seq,
75        params,
76        "clientInfo",
77        Value::is_object,
78        "an object",
79    );
80}
81
82fn expect_member(
83    sink: &mut FindingSink,
84    seq: u64,
85    params: &Value,
86    member: &str,
87    predicate: fn(&Value) -> bool,
88    expected: &str,
89) {
90    match params.get(member) {
91        None => sink.push(
92            Some(seq),
93            format!("initialize params lack the {member} member"),
94        ),
95        Some(value) if !predicate(value) => sink.push(
96            Some(seq),
97            format!("initialize params member {member} should be {expected}"),
98        ),
99        Some(_) => {}
100    }
101}
102
103/// `LIFE-003`: "After successful initialization, the client MUST send an `initialized`
104/// notification …".
105pub(super) fn initialized_notification(context: &TraceContext<'_>, sink: &mut FindingSink) {
106    let init = context.initialize();
107    if let Some((result_seq, _)) = init.result {
108        if init.initialized.is_none() {
109            sink.push(
110                Some(result_seq),
111                "the server answered initialize here, but no notifications/initialized notification follows in the trace".to_owned(),
112            );
113        }
114    }
115}
116
117/// `LIFE-004`: "The client SHOULD NOT send requests other than pings before the server
118/// has responded to the `initialize` request."
119pub(super) fn client_requests_before_init_response(
120    context: &TraceContext<'_>,
121    sink: &mut FindingSink,
122) {
123    for (event, kind, phase) in context.messages() {
124        if event.direction != Direction::ClientToServer {
125            continue;
126        }
127        if !matches!(
128            phase,
129            Phase::BeforeInitialize | Phase::AwaitingInitializeResult
130        ) {
131            continue;
132        }
133        if let MessageKind::Request { method, .. } = kind {
134            if *method != "initialize" && *method != "ping" {
135                sink.push(
136                    Some(event.seq),
137                    format!(
138                        "client sent a {method:?} request before the server responded to initialize"
139                    ),
140                );
141            }
142        }
143    }
144}
145
146/// `LIFE-005`: "The server SHOULD NOT send requests other than pings and logging before
147/// receiving the `initialized` notification."
148///
149/// In `2025-11-25`, logging travels as `notifications/message` — a notification, which
150/// this requests-only check never flags — so the spec's "and logging" allowance needs
151/// no special case here.
152pub(super) fn server_requests_before_initialized(
153    context: &TraceContext<'_>,
154    sink: &mut FindingSink,
155) {
156    for (event, kind, phase) in context.messages() {
157        if event.direction != Direction::ServerToClient || phase == Phase::Ready {
158            continue;
159        }
160        if let MessageKind::Request { method, .. } = kind {
161            if *method != "ping" {
162                sink.push(
163                    Some(event.seq),
164                    format!(
165                        "server sent a {method:?} request before receiving the initialized notification"
166                    ),
167                );
168            }
169        }
170    }
171}
172
173/// `LIFE-007`: "In the `initialize` request, the client MUST send a protocol version
174/// it supports." — presence and string-ness of the version is the wire-observable
175/// core; whether the client truly *supports* the version it sent is not in the trace.
176pub(super) fn initialize_protocol_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
177    let Some((seq, params)) = context.initialize().request else {
178        return; // No initialize at all: LIFE-001's finding.
179    };
180    match params.and_then(|params| params.get("protocolVersion")) {
181        None => sink.push(
182            Some(seq),
183            "initialize request sends no protocolVersion".to_owned(),
184        ),
185        Some(Value::String(_)) => {}
186        Some(other) => sink.push(
187            Some(seq),
188            format!("initialize request protocolVersion is {other}, expected a version string"),
189        ),
190    }
191}
192
193/// `LIFE-006`: the server's `initialize` result must carry a `protocolVersion` that is
194/// a dated revision identifier. Whether the *negotiation* (same-version-if-supported)
195/// was honored is not judgeable from a single trace; the shape and format are.
196pub(super) fn initialize_result_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
197    let Some((seq, result)) = context.initialize().result else {
198        return;
199    };
200    match result.get("protocolVersion") {
201        None => sink.push(
202            Some(seq),
203            "initialize result lacks the protocolVersion member".to_owned(),
204        ),
205        Some(Value::String(version)) => {
206            if version.parse::<ProtocolRevision>().is_err() {
207                sink.push(
208                    Some(seq),
209                    format!(
210                        "initialize result protocolVersion {version:?} is not a dated revision identifier (YYYY-MM-DD)"
211                    ),
212                );
213            }
214        }
215        Some(other) => sink.push(
216            Some(seq),
217            format!("initialize result protocolVersion is {other}, expected a revision string"),
218        ),
219    }
220}
221
222const fn describe_kind(kind: &MessageKind<'_>) -> &'static str {
223    match kind {
224        MessageKind::Request { .. } => "a request",
225        MessageKind::Notification { .. } => "a notification",
226        MessageKind::Result { .. } => "a result response",
227        MessageKind::Error { .. } => "an error response",
228        MessageKind::Invalid { .. } => "not a valid JSON-RPC message",
229        // MessageKind is #[non_exhaustive]; future shapes still deserve a description.
230        _ => "an unrecognized message kind",
231    }
232}
233
234const fn direction_name(direction: Direction) -> &'static str {
235    match direction {
236        Direction::ClientToServer => "client to server",
237        Direction::ServerToClient => "server to client",
238    }
239}
240
241#[cfg(test)]
242#[allow(clippy::unwrap_used, clippy::expect_used)]
243mod tests {
244    use super::*;
245    use serde_json::json;
246
247    #[test]
248    fn describe_kind_names_every_shape_exactly() {
249        // These strings appear verbatim in findings; mutating any arm must fail here.
250        let request = json!({"id": 1, "method": "x"});
251        let notification = json!({"method": "x"});
252        let result = json!({"id": 1, "result": {}});
253        let error = json!({"id": 1, "error": {}});
254        let invalid = json!([]);
255        let cases = [
256            (&request, "a request"),
257            (&notification, "a notification"),
258            (&result, "a result response"),
259            (&error, "an error response"),
260            (&invalid, "not a valid JSON-RPC message"),
261        ];
262        for (payload, expected) in cases {
263            let kind = mcp_conformance_core::message::classify(payload);
264            assert_eq!(describe_kind(&kind), expected, "for {payload}");
265        }
266    }
267
268    #[test]
269    fn direction_name_is_exact() {
270        assert_eq!(
271            direction_name(Direction::ClientToServer),
272            "client to server"
273        );
274        assert_eq!(
275            direction_name(Direction::ServerToClient),
276            "server to client"
277        );
278    }
279
280    #[test]
281    fn initialize_params_with_wrong_types_are_flagged() {
282        // Present-but-mistyped members must be findings, not silent passes — this
283        // pins the type-predicate guard in expect_member.
284        use crate::context::TraceContext;
285        use crate::reader::{Limits, parse_trace};
286        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"}}}"#;
287        let events = parse_trace(doc, &Limits::default()).expect("valid trace");
288        let context = TraceContext::new(&events);
289        let findings = crate::checks::find("lifecycle.initialize-params")
290            .expect("check exists")
291            .run(&context);
292        assert_eq!(findings.len(), 3, "{findings:?}");
293        assert!(
294            findings[0]
295                .detail
296                .contains("protocolVersion should be a string")
297        );
298        assert!(
299            findings[1]
300                .detail
301                .contains("capabilities should be an object")
302        );
303        assert!(
304            findings[2]
305                .detail
306                .contains("clientInfo should be an object")
307        );
308    }
309}