mcp_trace_validator/checks/base/
meta.rs1use serde_json::Value;
7
8use super::super::FindingSink;
9use crate::context::TraceContext;
10
11pub(in crate::checks) fn meta_key_format(context: &TraceContext<'_>, sink: &mut FindingSink) {
21 for (event, _, _) in context.messages() {
22 let Some(payload) = event.message_payload() else {
23 continue;
24 };
25 for envelope in ["params", "result"] {
26 let meta = payload
27 .get(envelope)
28 .and_then(|member| member.get("_meta"))
29 .and_then(Value::as_object);
30 let Some(meta) = meta else { continue };
31 for key in meta.keys() {
32 sink.examined();
36 if let Err(reason) = validate_meta_key(key) {
37 sink.push(
38 Some(event.seq),
39 format!("{envelope}._meta key {key:?} {reason}"),
40 );
41 }
42 }
43 }
44 }
45}
46
47pub(in crate::checks) fn validate_meta_key(key: &str) -> Result<(), String> {
52 let (prefix, name) = match key.split_once('/') {
53 Some((prefix, name)) => (Some(prefix), name),
54 None => (None, key),
55 };
56 if let Some(prefix) = prefix {
57 for label in prefix.split('.') {
58 let bytes = label.as_bytes();
59 let shape_ok = bytes.first().is_some_and(u8::is_ascii_alphabetic)
60 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
61 && bytes
62 .iter()
63 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-');
64 if !shape_ok {
65 return Err(format!(
66 "has prefix label {label:?}; labels must start with a letter, end with \
67 a letter or digit, and contain only letters, digits, or hyphens"
68 ));
69 }
70 }
71 }
72 if !name.is_empty() {
73 let bytes = name.as_bytes();
74 if !bytes.first().is_some_and(u8::is_ascii_alphanumeric)
75 || !bytes.last().is_some_and(u8::is_ascii_alphanumeric)
76 {
77 return Err(
78 "has a name that does not begin and end with an alphanumeric character".to_owned(),
79 );
80 }
81 if !bytes
82 .iter()
83 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
84 {
85 return Err(
86 "has a name with characters outside alphanumerics, hyphens, underscores, \
87 and dots"
88 .to_owned(),
89 );
90 }
91 }
92 Ok(())
93}
94
95#[cfg(test)]
96#[allow(clippy::unwrap_used, clippy::expect_used)]
97mod tests {
98 use crate::checks;
99 use crate::context::TraceContext;
100 use crate::reader::{Limits, parse_trace};
101 use crate::report::Finding;
102 use mcp_conformance_core::trace::TraceEvent;
103
104 fn run_check(check_id: &str, trace: &str) -> Vec<Finding> {
105 let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
106 let context = TraceContext::new(&events);
107 checks::find(check_id).unwrap().run(&context).findings
108 }
109
110 const INIT: &str = 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"}}}}"#;
111
112 #[test]
113 fn meta_key_grammar_table() {
114 use super::validate_meta_key;
115 for valid in [
116 "progressToken",
117 "x",
118 "n.a-me_0",
119 "com.example/key",
120 "com.example/",
121 "a/b",
122 "a1-b/n",
123 "io.modelcontextprotocol/x",
124 ] {
125 assert!(
126 validate_meta_key(valid).is_ok(),
127 "{valid:?} should be valid"
128 );
129 }
130 for (invalid, reason) in [
135 ("1bad/x", "prefix label"), ("bad-/x", "prefix label"), ("a..b/x", "prefix label"), ("/x", "prefix label"), ("a_b/x", "prefix label"), ("a/-x", "begin and end"), ("a/x.", "begin and end"), ("a/x y", "characters outside"), ("a/b/c", "characters outside"), ] {
145 let error =
146 validate_meta_key(invalid).expect_err(&format!("{invalid:?} should be invalid"));
147 assert!(
148 error.contains(reason),
149 "{invalid:?} should be rejected by the {reason:?} rule, got: {error}"
150 );
151 }
152 assert!(validate_meta_key("").is_ok());
155 }
156
157 #[test]
158 fn meta_key_format_scopes_to_envelope_meta_only() {
159 let trace = format!(
162 "{INIT}\n{}\n{}",
163 r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{"1bad./t":1}}}}"#,
164 r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"_meta":{"1bad./t":1}}}}}"#
165 );
166 let findings = run_check("base.meta-key-format", &trace);
167 assert_eq!(findings.len(), 1, "{findings:?}");
168 assert!(findings[0].detail.contains("params._meta"), "{findings:?}");
169 assert_eq!(findings[0].seq, Some(1));
170 }
171}