mcp_trace_validator/checks/
lifecycle.rs1use 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
18pub(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
43pub(super) fn initialize_params(context: &TraceContext<'_>, sink: &mut FindingSink) {
46 let Some((seq, params)) = context.initialize().request else {
47 return; };
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
103pub(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
117pub(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
147pub(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
174pub(super) fn initialize_protocol_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
178 let Some((seq, params)) = context.initialize().request else {
179 return; };
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
194pub(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
223pub(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 _ => "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 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 (¬ification, "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 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 assert!(
358 run(r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}"#)
359 .is_empty()
360 );
361 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 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 let wrong = run(r#"{"capabilities":7,"serverInfo":"s"}"#);
372 assert_eq!(wrong.len(), 2, "{wrong:?}");
373 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}