use std::collections::BTreeSet;
use super::super::super::FindingSink;
use super::{
Designation, Match, Post, compare, designations, designations_by_tool, header_safe,
is_miscased_sentinel, mirrors, posts, tool_definitions,
};
use crate::context::TraceContext;
#[cfg(test)]
mod tests;
const TCHAR: &[u8] = b"!#$%&'*+-.^_`|~";
const PRIMITIVE_TYPES: &[&str] = &["integer", "string", "boolean"];
pub(in crate::checks) fn protocol_version_header_present(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for post in posts(context) {
if !post.is_request() {
continue; }
sink.examined();
if !post.headers.contains_key("mcp-protocol-version") {
sink.push(
Some(post.seq),
"client POST lacks the MCP-Protocol-Version header".to_owned(),
);
}
}
}
pub(in crate::checks) fn protocol_version_header_matches_body(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for post in posts(context) {
if !post.is_request() {
continue;
}
let (Some(header), Some(body)) = (
post.headers.get("mcp-protocol-version"),
post.body_protocol_version(),
) else {
continue;
};
sink.examined();
if header != body {
sink.push(
Some(post.seq),
format!(
"MCP-Protocol-Version header is {header:?} but the body's \
`_meta` protocol version is {body:?}"
),
);
}
}
}
pub(in crate::checks) fn request_metadata_headers(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let designated = designations_by_tool(context);
for post in posts(context) {
if !post.is_request() {
continue;
}
for mirror in mirrors(&post, &designated) {
if mirror.header.starts_with("mcp-param-") {
continue; }
sink.examined();
let Some(sent) = post.headers.get(&mirror.header) else {
sink.push(
Some(post.seq),
format!(
"POST for `{}` lacks the required `{}` header",
post.method().unwrap_or_default(),
mirror.label
),
);
continue;
};
if compare(sent, &mirror.value) == Match::Mismatch {
sink.push(
Some(post.seq),
format!(
"`{}` header is {sent:?} but `{}` is {:?}",
mirror.label, mirror.source, mirror.value
),
);
}
}
}
}
fn encodable_headers<'a>(post: &Post<'a>) -> impl Iterator<Item = (&'a String, &'a String)> {
post.headers
.iter()
.filter(|(name, _)| *name == "mcp-name" || name.starts_with("mcp-param-"))
}
pub(in crate::checks) fn header_value_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
for post in posts(context) {
if !post.is_request() {
continue;
}
for (name, value) in encodable_headers(&post) {
if is_miscased_sentinel(value) {
continue;
}
sink.examined();
if !header_safe(value) {
sink.push(
Some(post.seq),
format!(
"header `{name}` carries {value:?} unencoded; a value that is not \
safely representable in ASCII must use the Base64 sentinel"
),
);
}
}
}
}
pub(in crate::checks) fn sentinel_marker_case(context: &TraceContext<'_>, sink: &mut FindingSink) {
for post in posts(context) {
if !post.is_request() {
continue;
}
for (name, value) in encodable_headers(&post) {
sink.examined();
if is_miscased_sentinel(value) {
sink.push(
Some(post.seq),
format!(
"header `{name}` carries {value:?}, whose Base64 sentinel markers \
are miscased; they are case-sensitive and must be lowercase"
),
);
}
}
}
}
pub(in crate::checks) fn sentinel_pattern_encoded(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let designated = designations_by_tool(context);
for post in posts(context) {
if !post.is_request() {
continue;
}
for mirror in mirrors(&post, &designated) {
if !mirror.encodable {
continue;
}
let Some(sent) = post.headers.get(&mirror.header) else {
continue;
};
sink.examined();
if compare(sent, &mirror.value) == Match::UnencodedSentinel {
sink.push(
Some(post.seq),
format!(
"`{}` carries {sent:?} verbatim; a value matching the sentinel \
pattern must itself be Base64-encoded to stay unambiguous",
mirror.label
),
);
}
}
}
}
pub(in crate::checks) fn x_mcp_header_mirrored(context: &TraceContext<'_>, sink: &mut FindingSink) {
let designated = designations_by_tool(context);
if designated.is_empty() {
return;
}
for post in posts(context) {
if !post.is_request() {
continue;
}
for mirror in mirrors(&post, &designated) {
if !mirror.header.starts_with("mcp-param-") {
continue; }
sink.examined();
if !post.headers.contains_key(&mirror.header) {
sink.push(
Some(post.seq),
format!(
"`tools/call` supplies `{}`, which the tool designates for header \
`{}`, but the POST does not carry it",
mirror.source, mirror.label
),
);
}
}
}
}
pub(in crate::checks) fn x_mcp_header_name_valid(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (seq, tool) in tool_definitions(context) {
let Some(schema) = tool.get("inputSchema") else {
continue;
};
let mut seen: BTreeSet<String> = BTreeSet::new();
for designation in designations(schema) {
sink.examined();
let duplicate = !seen.insert(designation.name.to_ascii_lowercase());
for reason in annotation_faults(&designation, duplicate) {
sink.push(
Some(seq),
format!(
"property `{}` designates `x-mcp-header` {:?}, which {reason}",
designation.path.join("."),
designation.name
),
);
}
}
}
}
fn annotation_faults(designation: &Designation, duplicate: bool) -> Vec<String> {
let mut faults = Vec::new();
if designation.name.is_empty() {
faults.push("is empty".to_owned());
} else if !designation
.name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || TCHAR.contains(&byte))
{
faults.push("is not an HTTP field-name token (`1*tchar`, RFC 9110 §5.1)".to_owned());
}
if duplicate {
faults.push(
"repeats an earlier `x-mcp-header` in this `inputSchema`; the values must be \
case-insensitively unique"
.to_owned(),
);
}
if let Some(declared) = designation.declared_type.as_deref()
&& !PRIMITIVE_TYPES.contains(&declared)
{
faults.push(format!(
"annotates a `{declared}` property; only integer, string and boolean \
parameters may carry one"
));
}
faults
}