Skip to main content

mcp_trace_validator/checks/base/
meta.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The `_meta` key-grammar check (`BASE-019`/`BASE-020`).
5
6use serde_json::Value;
7
8use super::super::FindingSink;
9use crate::context::TraceContext;
10
11/// `BASE-019`/`BASE-020`: `_meta` key grammar — an optional dotted-label
12/// prefix ending in `/`, then a name that begins and ends alphanumeric.
13///
14/// Scope: the `params._meta` and `result._meta` objects — the
15/// "property/parameter" positions the clauses name on the message envelope.
16/// `_meta` objects nested deeper (content items, tool definitions) share the
17/// grammar but collide with user-defined data (a tool's `arguments` may
18/// legitimately contain a member spelled `_meta`), so the envelope positions
19/// are the sound, false-positive-free scope.
20pub(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                // One `_meta` key is one subject: the clause is about the
33                // spelling of each key, so a session whose messages carry no
34                // `_meta` at all never exercised it.
35                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
47/// Validates one `_meta` key against the `2025-11-25` grammar: an optional
48/// `label(.label)*/` prefix (labels start with a letter, end with a letter or
49/// digit, interior letters/digits/hyphens) and a name that, unless empty,
50/// begins and ends alphanumeric with `-`/`_`/`.`/alphanumerics between.
51pub(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        // Each rejection's *reason* feeds `finding.detail` verbatim, so the
131        // table pins which rule fired — a defect routed to the wrong rule
132        // (say, a bad label reported as a bad name) is a wrong diagnostic
133        // even when the verdict is right.
134        for (invalid, reason) in [
135            ("1bad/x", "prefix label"),      // label starts with a digit
136            ("bad-/x", "prefix label"),      // label ends with a hyphen
137            ("a..b/x", "prefix label"),      // empty interior label
138            ("/x", "prefix label"),          // empty prefix label
139            ("a_b/x", "prefix label"),       // underscore not allowed in labels
140            ("a/-x", "begin and end"),       // name starts with a hyphen
141            ("a/x.", "begin and end"),       // name ends with a dot
142            ("a/x y", "characters outside"), // space in name
143            ("a/b/c", "characters outside"), // slash in name
144        ] {
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        // Documented edge: an empty name is allowed ("Unless empty…"), and a
153        // bare empty key has no prefix either.
154        assert!(validate_meta_key("").is_ok());
155    }
156
157    #[test]
158    fn meta_key_format_scopes_to_envelope_meta_only() {
159        // params._meta violations are findings; identical spellings inside
160        // user data (tool arguments) are not.
161        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}