use std::collections::BTreeMap;
use serde_json::Value;
use super::super::support::decode_base64;
use crate::context::TraceContext;
use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
mod headers;
mod stdio;
mod stream;
mod validation;
#[cfg(test)]
mod tests;
pub(in crate::checks) use headers::{
header_value_encoding, protocol_version_header_matches_body, protocol_version_header_present,
request_metadata_headers, sentinel_marker_case, sentinel_pattern_encoded,
x_mcp_header_mirrored, x_mcp_header_name_valid,
};
pub(in crate::checks) use stdio::{
cancel_notification_references_request, no_messages_after_cancel_notification,
};
pub(in crate::checks) use stream::{
accel_buffering_header, client_no_responses, no_independent_server_requests,
no_messages_after_cancellation,
};
pub(in crate::checks) use validation::{
header_body_match_validated, header_mismatch_status, invalid_param_header_rejected,
unknown_method_404, unsupported_version_error, unsupported_version_status,
version_mismatch_rejected,
};
pub(super) const NAME_SOURCED: &[(&str, &str)] = &[
("tools/call", "name"),
("prompts/get", "name"),
("resources/read", "uri"),
];
pub(super) const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const SENTINEL_OPEN: &str = "=?base64?";
const SENTINEL_CLOSE: &str = "?=";
#[derive(Debug, Clone, Copy)]
pub(super) struct Post<'a> {
pub seq: u64,
pub message_seq: u64,
pub headers: &'a BTreeMap<String, String>,
pub payload: &'a Value,
}
impl Post<'_> {
pub(super) fn method(&self) -> Option<&str> {
self.payload.get("method").and_then(Value::as_str)
}
pub(super) fn is_request(&self) -> bool {
self.method().is_some() && self.payload.get("id").is_some_and(|id| !id.is_null())
}
pub(super) fn body_protocol_version(&self) -> Option<&str> {
self.payload
.get("params")?
.get("_meta")?
.get(META_PROTOCOL_VERSION)?
.as_str()
}
}
pub(super) fn posts<'a>(context: &'a TraceContext<'_>) -> Vec<Post<'a>> {
let mut out = Vec::new();
let events = context.events();
for (index, event) in events.iter().enumerate() {
if event.direction != Direction::ClientToServer
|| event.transport != TransportKind::StreamableHttp
{
continue;
}
let EventBody::Http { headers, .. } = &event.body else {
continue;
};
let framed = events[index + 1..]
.iter()
.find(|later| later.direction == Direction::ClientToServer);
if let Some(framed) = framed
&& let Some(payload) = framed.message_payload()
{
out.push(Post {
seq: event.seq,
message_seq: framed.seq,
headers,
payload,
});
}
}
out
}
pub(super) fn posts_by_message<'a>(context: &'a TraceContext<'_>) -> BTreeMap<u64, Post<'a>> {
posts(context)
.into_iter()
.map(|post| (post.message_seq, post))
.collect()
}
pub(super) fn header_safe(value: &str) -> bool {
value.trim() == value
&& value
.bytes()
.all(|byte| byte == 0x09 || (0x20..0x7f).contains(&byte))
}
pub(super) fn sentinel_payload(value: &str) -> Option<&str> {
value
.strip_prefix(SENTINEL_OPEN)
.and_then(|rest| rest.strip_suffix(SENTINEL_CLOSE))
}
pub(super) fn is_miscased_sentinel(value: &str) -> bool {
let folded = value.to_ascii_lowercase();
sentinel_payload(value).is_none() && sentinel_payload(&folded).is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Match {
Carried,
UnencodedSentinel,
Mismatch,
}
pub(super) fn compare(header: &str, body: &str) -> Match {
if header == body {
return if sentinel_payload(body).is_some() {
Match::UnencodedSentinel
} else {
Match::Carried
};
}
if let Some(encoded) = sentinel_payload(header)
&& decode_base64(encoded).as_deref() == Some(body)
{
return Match::Carried;
}
Match::Mismatch
}
#[derive(Debug, Clone)]
pub(super) struct Designation {
pub path: Vec<String>,
pub name: String,
pub header: String,
pub declared_type: Option<String>,
}
pub(super) fn tool_definitions<'a>(
context: &'a TraceContext<'_>,
) -> impl Iterator<Item = (u64, &'a Value)> + 'a {
context.messages().flat_map(|(event, _, _)| {
event
.message_payload()
.and_then(|payload| payload.get("result"))
.and_then(|result| result.get("tools"))
.and_then(Value::as_array)
.map(|tools| tools.iter().map(move |tool| (event.seq, tool)))
.into_iter()
.flatten()
})
}
pub(super) fn designations(schema: &Value) -> Vec<Designation> {
let mut out = Vec::new();
collect_designations(schema, &mut Vec::new(), &mut out);
out
}
fn collect_designations(schema: &Value, path: &mut Vec<String>, out: &mut Vec<Designation>) {
let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
return;
};
for (property, subschema) in properties {
path.push(property.clone());
if let Some(name) = subschema.get("x-mcp-header").and_then(Value::as_str) {
out.push(Designation {
path: path.clone(),
name: name.to_owned(),
header: format!("mcp-param-{}", name.to_ascii_lowercase()),
declared_type: subschema
.get("type")
.and_then(Value::as_str)
.map(str::to_owned),
});
}
collect_designations(subschema, path, out);
path.pop();
}
}
pub(in crate::checks::draft) fn designations_by_tool(
context: &TraceContext<'_>,
) -> BTreeMap<String, Vec<Designation>> {
let mut out = BTreeMap::new();
for (_, tool) in tool_definitions(context) {
let Some(name) = tool.get("name").and_then(Value::as_str) else {
continue;
};
let Some(schema) = tool.get("inputSchema") else {
continue;
};
let declared = designations(schema);
if !declared.is_empty() {
out.insert(name.to_owned(), declared);
}
}
out
}
fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
let mut cursor = root;
for step in path {
cursor = cursor.get(step)?;
}
Some(cursor)
}
fn header_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Bool(flag) => Some(flag.to_string()),
Value::Number(number) if number.is_i64() || number.is_u64() => Some(number.to_string()),
_ => None,
}
}
#[derive(Debug, Clone)]
pub(super) struct Mirror {
pub header: String,
pub label: String,
pub source: String,
pub value: String,
pub encodable: bool,
}
pub(super) fn mirrors(
post: &Post<'_>,
designated: &BTreeMap<String, Vec<Designation>>,
) -> Vec<Mirror> {
let mut out = Vec::new();
let Some(method) = post.method() else {
return out;
};
out.push(Mirror {
header: "mcp-method".to_owned(),
label: "Mcp-Method".to_owned(),
source: "method".to_owned(),
value: method.to_owned(),
encodable: false,
});
let params = post.payload.get("params");
if let Some((_, field)) = NAME_SOURCED.iter().find(|(name, _)| *name == method)
&& let Some(value) = params
.and_then(|params| params.get(*field))
.and_then(Value::as_str)
{
out.push(Mirror {
header: "mcp-name".to_owned(),
label: "Mcp-Name".to_owned(),
source: format!("params.{field}"),
value: value.to_owned(),
encodable: true,
});
}
let tool = params
.and_then(|params| params.get("name"))
.and_then(Value::as_str);
let declared = (method == "tools/call")
.then(|| tool.and_then(|tool| designated.get(tool)))
.flatten();
let arguments = params.and_then(|params| params.get("arguments"));
for designation in declared.into_iter().flatten() {
let Some(value) = arguments
.and_then(|arguments| value_at(arguments, &designation.path))
.and_then(header_text)
else {
continue;
};
out.push(Mirror {
header: designation.header.clone(),
label: format!("Mcp-Param-{}", designation.name),
source: format!("params.arguments.{}", designation.path.join(".")),
value,
encodable: true,
});
}
out
}