use serde_json::Value;
use super::super::FindingSink;
use crate::context::TraceContext;
pub(in crate::checks) fn meta_key_format(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, _, _) in context.messages() {
let Some(payload) = event.message_payload() else {
continue;
};
for envelope in ["params", "result"] {
let meta = payload
.get(envelope)
.and_then(|member| member.get("_meta"))
.and_then(Value::as_object);
let Some(meta) = meta else { continue };
for key in meta.keys() {
sink.examined();
if let Err(reason) = validate_meta_key(key) {
sink.push(
Some(event.seq),
format!("{envelope}._meta key {key:?} {reason}"),
);
}
}
}
}
}
pub(in crate::checks) fn validate_meta_key(key: &str) -> Result<(), String> {
let (prefix, name) = match key.split_once('/') {
Some((prefix, name)) => (Some(prefix), name),
None => (None, key),
};
if let Some(prefix) = prefix {
for label in prefix.split('.') {
let bytes = label.as_bytes();
let shape_ok = bytes.first().is_some_and(u8::is_ascii_alphabetic)
&& bytes.last().is_some_and(u8::is_ascii_alphanumeric)
&& bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-');
if !shape_ok {
return Err(format!(
"has prefix label {label:?}; labels must start with a letter, end with \
a letter or digit, and contain only letters, digits, or hyphens"
));
}
}
}
if !name.is_empty() {
let bytes = name.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_alphanumeric)
|| !bytes.last().is_some_and(u8::is_ascii_alphanumeric)
{
return Err(
"has a name that does not begin and end with an alphanumeric character".to_owned(),
);
}
if !bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(
"has a name with characters outside alphanumerics, hyphens, underscores, \
and dots"
.to_owned(),
);
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use crate::checks;
use crate::context::TraceContext;
use crate::reader::{Limits, parse_trace};
use crate::report::Finding;
use mcp_conformance_core::trace::TraceEvent;
fn run_check(check_id: &str, trace: &str) -> Vec<Finding> {
let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
let context = TraceContext::new(&events);
checks::find(check_id).unwrap().run(&context).findings
}
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"}}}}"#;
#[test]
fn meta_key_grammar_table() {
use super::validate_meta_key;
for valid in [
"progressToken",
"x",
"n.a-me_0",
"com.example/key",
"com.example/",
"a/b",
"a1-b/n",
"io.modelcontextprotocol/x",
] {
assert!(
validate_meta_key(valid).is_ok(),
"{valid:?} should be valid"
);
}
for (invalid, reason) in [
("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"), ] {
let error =
validate_meta_key(invalid).expect_err(&format!("{invalid:?} should be invalid"));
assert!(
error.contains(reason),
"{invalid:?} should be rejected by the {reason:?} rule, got: {error}"
);
}
assert!(validate_meta_key("").is_ok());
}
#[test]
fn meta_key_format_scopes_to_envelope_meta_only() {
let trace = format!(
"{INIT}\n{}\n{}",
r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{"1bad./t":1}}}}"#,
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}}}}}"#
);
let findings = run_check("base.meta-key-format", &trace);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].detail.contains("params._meta"), "{findings:?}");
assert_eq!(findings[0].seq, Some(1));
}
}