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) {
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
46pub(super) fn initialize_params(context: &TraceContext<'_>, sink: &mut FindingSink) {
49 let Some((seq, params)) = context.initialize().request else {
50 return; };
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
107pub(super) fn initialized_notification(context: &TraceContext<'_>, sink: &mut FindingSink) {
110 let init = context.initialize();
111 let Some((result_seq, _)) = init.result else {
112 return; };
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
123pub(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 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
158pub(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 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
190pub(super) fn initialize_protocol_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
194 let Some((seq, params)) = context.initialize().request else {
195 return; };
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
211pub(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
241pub(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 _ => "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 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 (¬ification, "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 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 assert!(
379 run(r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}"#)
380 .is_empty()
381 );
382 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 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 let wrong = run(r#"{"capabilities":7,"serverInfo":"s"}"#);
393 assert_eq!(wrong.len(), 2, "{wrong:?}");
394 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}