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 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
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 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
146pub(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
173pub(super) fn initialize_protocol_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
177 let Some((seq, params)) = context.initialize().request else {
178 return; };
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
193pub(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 _ => "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 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 (¬ification, "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 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}