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        && init.initialized.is_none()
109    {
110        sink.push(
111                Some(result_seq),
112                "the server answered initialize here, but no notifications/initialized notification follows in the trace".to_owned(),
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            && *method != "initialize"
135            && *method != "ping"
136        {
137            sink.push(
138                Some(event.seq),
139                format!(
140                    "client sent a {method:?} request before the server responded to initialize"
141                ),
142            );
143        }
144    }
145}
146
147/// `LIFE-005`: "The server SHOULD NOT send requests other than pings and logging before
148/// receiving the `initialized` notification."
149///
150/// In `2025-11-25`, logging travels as `notifications/message` — a notification, which
151/// this requests-only check never flags — so the spec's "and logging" allowance needs
152/// no special case here.
153pub(super) fn server_requests_before_initialized(
154    context: &TraceContext<'_>,
155    sink: &mut FindingSink,
156) {
157    for (event, kind, phase) in context.messages() {
158        if event.direction != Direction::ServerToClient || phase == Phase::Ready {
159            continue;
160        }
161        if let MessageKind::Request { method, .. } = kind
162            && *method != "ping"
163        {
164            sink.push(
165                Some(event.seq),
166                format!(
167                    "server sent a {method:?} request before receiving the initialized notification"
168                ),
169            );
170        }
171    }
172}
173
174/// `LIFE-007`: "In the `initialize` request, the client MUST send a protocol version
175/// it supports." — presence and string-ness of the version is the wire-observable
176/// core; whether the client truly *supports* the version it sent is not in the trace.
177pub(super) fn initialize_protocol_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
178    let Some((seq, params)) = context.initialize().request else {
179        return; // No initialize at all: LIFE-001's finding.
180    };
181    match params.and_then(|params| params.get("protocolVersion")) {
182        None => sink.push(
183            Some(seq),
184            "initialize request sends no protocolVersion".to_owned(),
185        ),
186        Some(Value::String(_)) => {}
187        Some(other) => sink.push(
188            Some(seq),
189            format!("initialize request protocolVersion is {other}, expected a version string"),
190        ),
191    }
192}
193
194/// `LIFE-006`: the server's `initialize` result must carry a `protocolVersion` that is
195/// a dated revision identifier. Whether the *negotiation* (same-version-if-supported)
196/// was honored is not judgeable from a single trace; the shape and format are.
197pub(super) fn initialize_result_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
198    let Some((seq, result)) = context.initialize().result else {
199        return;
200    };
201    match result.get("protocolVersion") {
202        None => sink.push(
203            Some(seq),
204            "initialize result lacks the protocolVersion member".to_owned(),
205        ),
206        Some(Value::String(version)) => {
207            if version.parse::<ProtocolRevision>().is_err() {
208                sink.push(
209                    Some(seq),
210                    format!(
211                        "initialize result protocolVersion {version:?} is not a dated revision identifier (YYYY-MM-DD)"
212                    ),
213                );
214            }
215        }
216        Some(other) => sink.push(
217            Some(seq),
218            format!("initialize result protocolVersion is {other}, expected a revision string"),
219        ),
220    }
221}
222
223/// `LIFE-010`: the initialize result must carry the server's capabilities and
224/// implementation information (`capabilities` and `serverInfo` objects).
225///
226/// A missing or error-answered initialize exchange is owned by the handshake
227/// checks (LIFE-001/003/006); this one judges only a result that exists.
228pub(super) fn initialize_result_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
229    let Some((seq, result)) = context.initialize().result else {
230        return;
231    };
232    for (member, label) in [
233        ("capabilities", "its capabilities"),
234        ("serverInfo", "its implementation information (serverInfo)"),
235    ] {
236        match result.get(member) {
237            None => sink.push(
238                Some(seq),
239                format!("initialize result lacks {label}: no {member} member"),
240            ),
241            Some(value) if !value.is_object() => sink.push(
242                Some(seq),
243                format!("initialize result {member} is {value}, expected an object"),
244            ),
245            Some(_) => {}
246        }
247    }
248}
249
250const fn describe_kind(kind: &MessageKind<'_>) -> &'static str {
251    match kind {
252        MessageKind::Request { .. } => "a request",
253        MessageKind::Notification { .. } => "a notification",
254        MessageKind::Result { .. } => "a result response",
255        MessageKind::Error { .. } => "an error response",
256        MessageKind::Invalid { .. } => "not a valid JSON-RPC message",
257        // MessageKind is #[non_exhaustive]; future shapes still deserve a description.
258        _ => "an unrecognized message kind",
259    }
260}
261
262const fn direction_name(direction: Direction) -> &'static str {
263    match direction {
264        Direction::ClientToServer => "client to server",
265        Direction::ServerToClient => "server to client",
266    }
267}
268
269#[cfg(test)]
270#[allow(clippy::unwrap_used, clippy::expect_used)]
271mod tests {
272    use super::*;
273    use serde_json::json;
274
275    #[test]
276    fn describe_kind_names_every_shape_exactly() {
277        // These strings appear verbatim in findings; mutating any arm must fail here.
278        let request = json!({"id": 1, "method": "x"});
279        let notification = json!({"method": "x"});
280        let result = json!({"id": 1, "result": {}});
281        let error = json!({"id": 1, "error": {}});
282        let invalid = json!([]);
283        let cases = [
284            (&request, "a request"),
285            (&notification, "a notification"),
286            (&result, "a result response"),
287            (&error, "an error response"),
288            (&invalid, "not a valid JSON-RPC message"),
289        ];
290        for (payload, expected) in cases {
291            let kind = mcp_conformance_core::message::classify(payload);
292            assert_eq!(describe_kind(&kind), expected, "for {payload}");
293        }
294    }
295
296    #[test]
297    fn direction_name_is_exact() {
298        assert_eq!(
299            direction_name(Direction::ClientToServer),
300            "client to server"
301        );
302        assert_eq!(
303            direction_name(Direction::ServerToClient),
304            "server to client"
305        );
306    }
307
308    #[test]
309    fn initialize_params_with_wrong_types_are_flagged() {
310        // Present-but-mistyped members must be findings, not silent passes — this
311        // pins the type-predicate guard in expect_member.
312        use crate::context::TraceContext;
313        use crate::reader::{Limits, parse_trace};
314        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"}}}"#;
315        let events = parse_trace(doc, &Limits::default()).expect("valid trace");
316        let context = TraceContext::new(&events);
317        let findings = crate::checks::find("lifecycle.initialize-params")
318            .expect("check exists")
319            .run(&context);
320        assert_eq!(findings.len(), 3, "{findings:?}");
321        assert!(
322            findings[0]
323                .detail
324                .contains("protocolVersion should be a string")
325        );
326        assert!(
327            findings[1]
328                .detail
329                .contains("capabilities should be an object")
330        );
331        assert!(
332            findings[2]
333                .detail
334                .contains("clientInfo should be an object")
335        );
336    }
337
338    #[test]
339    fn initialize_result_shape_demands_capability_and_serverinfo_objects() {
340        fn handshake_with_result(result: &str) -> String {
341            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"}}}}"#;
342            format!(
343                "{request}\n{{\"seq\":1,\"direction\":\"server-to-client\",\"transport\":\"stdio\",\"kind\":\"message\",\"payload\":{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{result}}}}}"
344            )
345        }
346        let run = |result: &str| {
347            let trace = handshake_with_result(result);
348            let events = crate::reader::parse_trace(&trace, &crate::reader::Limits::default())
349                .expect("trace parses");
350            let context = TraceContext::new(&events);
351            crate::checks::find("lifecycle.initialize-result-shape")
352                .expect("check registered")
353                .run(&context)
354        };
355
356        // Complete shape: no findings.
357        assert!(
358            run(r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}"#)
359                .is_empty()
360        );
361        // Missing capabilities only.
362        let missing_caps =
363            run(r#"{"protocolVersion":"2025-11-25","serverInfo":{"name":"s","version":"0"}}"#);
364        assert_eq!(missing_caps.len(), 1, "{missing_caps:?}");
365        assert!(missing_caps[0].detail.contains("capabilities"));
366        // Missing serverInfo only.
367        let missing_info = run(r#"{"protocolVersion":"2025-11-25","capabilities":{}}"#);
368        assert_eq!(missing_info.len(), 1, "{missing_info:?}");
369        assert!(missing_info[0].detail.contains("serverInfo"));
370        // Wrong types are findings too, one per member.
371        let wrong = run(r#"{"capabilities":7,"serverInfo":"s"}"#);
372        assert_eq!(wrong.len(), 2, "{wrong:?}");
373        // No initialize result at all: the handshake checks own that case.
374        let events = crate::reader::parse_trace("", &crate::reader::Limits::default()).unwrap();
375        let context = TraceContext::new(&events);
376        assert!(
377            crate::checks::find("lifecycle.initialize-result-shape")
378                .unwrap()
379                .run(&context)
380                .is_empty()
381        );
382    }
383}