mcp_trace_validator/checks/draft/
meta.rs1use std::collections::BTreeMap;
14
15use serde_json::Value;
16
17use super::super::FindingSink;
18use super::http_status_for;
19use crate::context::TraceContext;
20use mcp_conformance_core::trace::Direction;
21
22mod trace_context;
23
24#[cfg(test)]
25mod tests;
26
27pub(in crate::checks) use trace_context::trace_context_format;
28
29const REQUIRED_REQUEST_FIELDS: &[&str] = &[
31 "io.modelcontextprotocol/protocolVersion",
32 "io.modelcontextprotocol/clientCapabilities",
33];
34
35const MISSING_CAPABILITY_CODE: i64 = -32021;
37const INVALID_PARAMS: i64 = -32602;
39
40const LEGACY_HANDSHAKE: &str = "initialize";
43
44fn params_meta(payload: &Value) -> Option<&serde_json::Map<String, Value>> {
46 payload.get("params")?.get("_meta")?.as_object()
47}
48
49fn client_requests<'a>(
51 context: &'a TraceContext<'_>,
52) -> impl Iterator<Item = (u64, Option<&'a Value>, &'a Value)> + 'a {
53 context.messages().filter_map(|(event, _, _)| {
54 if !matches!(event.direction, Direction::ClientToServer) {
55 return None;
56 }
57 let payload = event.message_payload()?;
58 payload.get("method")?;
59 Some((event.seq, payload.get("id"), payload))
60 })
61}
62
63pub(in crate::checks) fn required_request_fields(
69 context: &TraceContext<'_>,
70 sink: &mut FindingSink,
71) {
72 for (seq, id, payload) in client_requests(context) {
73 if id.is_none() {
74 continue; }
76 sink.examined();
77 let meta = params_meta(payload);
78 for field in REQUIRED_REQUEST_FIELDS {
79 let present = meta.is_some_and(|meta| meta.contains_key(*field));
80 if !present {
81 sink.push(
82 Some(seq),
83 format!("request `_meta` is missing required field `{field}`"),
84 );
85 }
86 }
87 }
88}
89
90fn malformed_requests(context: &TraceContext<'_>) -> BTreeMap<String, u64> {
96 client_requests(context)
97 .filter_map(|(seq, id, payload)| {
98 let id = id?;
99 if payload.get("method").and_then(Value::as_str) == Some(LEGACY_HANDSHAKE) {
109 return None;
110 }
111 let meta = params_meta(payload);
112 let complete = REQUIRED_REQUEST_FIELDS
113 .iter()
114 .all(|field| meta.is_some_and(|meta| meta.contains_key(*field)));
115 (!complete).then(|| (id.to_string(), seq))
116 })
117 .collect()
118}
119
120pub(in crate::checks) fn missing_required_field_rejected(
127 context: &TraceContext<'_>,
128 sink: &mut FindingSink,
129) {
130 let malformed = malformed_requests(context);
131 if malformed.is_empty() {
132 return;
133 }
134 for (event, _, _) in context.messages() {
135 if !matches!(event.direction, Direction::ServerToClient) {
136 continue;
137 }
138 let Some(payload) = event.message_payload() else {
139 continue;
140 };
141 let Some(id) = payload.get("id") else {
142 continue;
143 };
144 let Some(&request_seq) = malformed.get(&id.to_string()) else {
145 continue;
146 };
147 sink.examined();
150 match payload.get("error").and_then(|error| error.get("code")) {
151 Some(code) if code.as_i64() == Some(INVALID_PARAMS) => {}
152 Some(code) => sink.push(
153 Some(event.seq),
154 format!(
155 "request at seq {request_seq} was missing a required `_meta` field; \
156 the server answered with error code {code} rather than {INVALID_PARAMS}"
157 ),
158 ),
159 None => sink.push(
160 Some(event.seq),
161 format!(
162 "request at seq {request_seq} was missing a required `_meta` field; \
163 the server answered with a result rather than error {INVALID_PARAMS}"
164 ),
165 ),
166 }
167 }
168}
169
170fn http_status_for_error(
181 context: &TraceContext<'_>,
182 sink: &mut FindingSink,
183 code: i64,
184 clause: &str,
185 answering: Option<&BTreeMap<String, u64>>,
186) {
187 for (event, _, _) in context.messages() {
188 if !matches!(event.direction, Direction::ServerToClient) {
189 continue;
190 }
191 let Some(payload) = event.message_payload() else {
192 continue;
193 };
194 let matches_code = payload
195 .get("error")
196 .and_then(|error| error.get("code"))
197 .and_then(Value::as_i64)
198 == Some(code);
199 if !matches_code {
200 continue;
201 }
202 if let Some(answering) = answering {
203 let answers_a_subject = payload
204 .get("id")
205 .is_some_and(|id| answering.contains_key(&id.to_string()));
206 if !answers_a_subject {
207 continue;
208 }
209 }
210 let Some((status_seq, status)) = http_status_for(context, event.seq) else {
213 continue;
214 };
215 sink.examined();
216 if status != 400 {
217 sink.push(
218 Some(status_seq),
219 format!("{clause}: error {code} was returned with HTTP {status}, not 400"),
220 );
221 }
222 }
223}
224
225pub(in crate::checks) fn missing_required_field_http_status(
227 context: &TraceContext<'_>,
228 sink: &mut FindingSink,
229) {
230 let malformed = malformed_requests(context);
236 http_status_for_error(
237 context,
238 sink,
239 INVALID_PARAMS,
240 "missing required `_meta` field",
241 Some(&malformed),
242 );
243}
244
245pub(in crate::checks) fn missing_capability_http_status(
247 context: &TraceContext<'_>,
248 sink: &mut FindingSink,
249) {
250 http_status_for_error(
251 context,
252 sink,
253 MISSING_CAPABILITY_CODE,
254 "missing required client capability",
255 None,
256 );
257}
258
259pub(in crate::checks) fn missing_capability_error(
280 context: &TraceContext<'_>,
281 sink: &mut FindingSink,
282) {
283 for (event, _, _) in context.messages() {
284 let Some(error) = event
285 .message_payload()
286 .and_then(|payload| payload.get("error"))
287 else {
288 continue;
289 };
290 if error.get("code").and_then(Value::as_i64) != Some(MISSING_CAPABILITY_CODE) {
291 continue;
292 }
293 sink.examined();
294 match error
295 .get("data")
296 .and_then(|data| data.get("requiredCapabilities"))
297 {
298 Some(required) if required.is_object() => {
299 if required.as_object().is_some_and(serde_json::Map::is_empty) {
300 sink.push(
301 Some(event.seq),
302 format!(
303 "error {MISSING_CAPABILITY_CODE} carries an empty \
304 `data.requiredCapabilities`; it must name the missing capabilities"
305 ),
306 );
307 }
308 }
309 Some(_) => sink.push(
310 Some(event.seq),
311 format!(
312 "error {MISSING_CAPABILITY_CODE} has `data.requiredCapabilities` \
313 that is not a `ClientCapabilities` object"
314 ),
315 ),
316 None => sink.push(
317 Some(event.seq),
318 format!(
319 "error {MISSING_CAPABILITY_CODE} has no `data.requiredCapabilities` \
320 naming the missing capabilities"
321 ),
322 ),
323 }
324 }
325}
326
327pub(in crate::checks) fn no_undeclared_capability_reliance(
333 context: &TraceContext<'_>,
334 sink: &mut FindingSink,
335) {
336 let mut declared: BTreeMap<String, Vec<String>> = BTreeMap::new();
339 for (_, id, payload) in client_requests(context) {
340 let Some(id) = id else { continue };
341 let names = params_meta(payload)
342 .and_then(|meta| meta.get("io.modelcontextprotocol/clientCapabilities"))
343 .and_then(Value::as_object)
344 .map(|caps| caps.keys().cloned().collect())
345 .unwrap_or_default();
346 declared.insert(id.to_string(), names);
347 }
348 for (event, _, _) in context.messages() {
349 if !matches!(event.direction, Direction::ServerToClient) {
350 continue;
351 }
352 let Some(payload) = event.message_payload() else {
353 continue;
354 };
355 let Some(result) = payload.get("result") else {
356 continue;
357 };
358 if result.get("resultType").and_then(Value::as_str) != Some("input_required") {
359 continue;
360 }
361 let Some(id) = payload.get("id") else {
362 continue;
363 };
364 let Some(declared) = declared.get(&id.to_string()) else {
365 continue;
366 };
367 let requests = result
368 .get("inputRequests")
369 .and_then(Value::as_object)
370 .map(|map| map.values().collect::<Vec<_>>())
371 .unwrap_or_default();
372 for request in requests {
373 let Some(method) = request.get("method").and_then(Value::as_str) else {
374 continue;
375 };
376 let needed = match method {
377 "elicitation/create" => "elicitation",
378 "sampling/createMessage" => "sampling",
379 "roots/list" => "roots",
380 _ => continue,
381 };
382 sink.examined();
385 if !declared.iter().any(|name| name == needed) {
386 sink.push(
387 Some(event.seq),
388 format!(
389 "server asked for `{method}`, which needs the `{needed}` capability, \
390 but the request's `clientCapabilities` did not declare it"
391 ),
392 );
393 }
394 }
395 }
396}
397
398pub(in crate::checks) fn subscription_id_present(
405 context: &TraceContext<'_>,
406 sink: &mut FindingSink,
407) {
408 let listening = context.messages().any(|(event, _, _)| {
409 event
410 .message_payload()
411 .and_then(|payload| payload.get("method"))
412 .and_then(Value::as_str)
413 == Some("subscriptions/listen")
414 });
415 if !listening {
416 return;
417 }
418 for (event, _, _) in context.messages() {
419 if !matches!(event.direction, Direction::ServerToClient) {
420 continue;
421 }
422 let Some(payload) = event.message_payload() else {
423 continue;
424 };
425 if payload.get("id").is_some() || payload.get("method").is_none() {
427 continue;
428 }
429 let method = payload.get("method").and_then(Value::as_str).unwrap_or("");
430 if method.starts_with("notifications/progress")
433 || method.starts_with("notifications/message")
434 {
435 continue;
436 }
437 sink.examined();
438 let tagged = params_meta(payload)
439 .is_some_and(|meta| meta.contains_key("io.modelcontextprotocol/subscriptionId"));
440 if !tagged {
441 sink.push(
442 Some(event.seq),
443 format!(
444 "notification `{method}` on a subscriptions/listen stream has no \
445 `io.modelcontextprotocol/subscriptionId` in `_meta`"
446 ),
447 );
448 }
449 }
450}