use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
use super::super::FindingSink;
use crate::context::TraceContext;
pub(in crate::checks) fn client_messages_use_post(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let mut carrying: Option<(u64, &str)> = None;
for event in context.events() {
if event.transport != TransportKind::StreamableHttp
|| event.direction != Direction::ClientToServer
{
continue;
}
match &event.body {
EventBody::Http { method, .. } => {
carrying = method.as_deref().map(|method| (event.seq, method));
}
EventBody::Message { .. } => {
let Some((request_seq, method)) = carrying else {
continue;
};
sink.examined();
if method != "POST" {
sink.push(
Some(event.seq),
format!(
"client JSON-RPC message rode an HTTP {method} request (seq \
{request_seq}); messages must be sent by POST"
),
);
}
}
_ => {}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use mcp_conformance_core::trace::TraceEvent;
use crate::checks;
use crate::context::TraceContext;
use crate::reader::{Limits, parse_trace};
const CHECK: &str = "transport.client-messages-use-post";
fn outcome(lines: &[String]) -> (Vec<String>, u32) {
let document = lines.join("\n");
let events: Vec<TraceEvent> = parse_trace(&document, &Limits::default()).unwrap();
let context = TraceContext::new(&events);
let outcome = checks::find(CHECK).unwrap().run(&context);
(
outcome
.findings
.into_iter()
.map(|finding| finding.detail)
.collect(),
outcome.subjects,
)
}
fn http(seq: u64, method: Option<&str>) -> String {
let verb = method.map_or_else(String::new, |method| format!(r#""method":"{method}","#));
format!(
r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"http",{verb}"headers":{{}}}}"#
)
}
fn message(seq: u64) -> String {
format!(
r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"method":"tools/list"}}}}"#
)
}
#[test]
fn a_message_carried_by_a_post_passes() {
let (findings, subjects) = outcome(&[http(0, Some("POST")), message(1)]);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(subjects, 1);
}
#[test]
fn a_message_carried_by_another_verb_is_a_violation() {
let (findings, _) = outcome(&[http(0, Some("PUT")), message(1)]);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].contains("PUT"), "{findings:?}");
assert!(findings[0].contains("seq 0"), "{findings:?}");
}
#[test]
fn a_teardown_delete_carries_no_message_and_is_not_judged() {
let (findings, subjects) =
outcome(&[http(0, Some("POST")), message(1), http(2, Some("DELETE"))]);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(subjects, 1, "only the POST's message was a subject");
}
#[test]
fn a_stream_opening_get_is_not_credited_with_the_previous_posts_message() {
let (findings, subjects) = outcome(&[
http(0, Some("POST")),
message(1),
http(2, Some("GET")),
message(3),
]);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].contains("GET"), "{findings:?}");
assert_eq!(subjects, 2);
}
#[test]
fn messages_with_no_recorded_request_are_not_judged() {
let (findings, subjects) = outcome(&[message(0), message(1)]);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(subjects, 0);
let (findings, subjects) = outcome(&[http(0, None), message(1)]);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(subjects, 0);
}
#[test]
fn a_servers_reply_is_not_the_clients_message() {
let reply = r#"{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}}"#.to_owned();
let (findings, subjects) = outcome(&[http(0, Some("POST")), message(1), reply]);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(subjects, 1, "only the client's message rode the POST");
}
#[test]
fn stdio_messages_are_out_of_scope() {
let stdio = [
r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#.to_owned(),
];
let (findings, subjects) = outcome(&stdio);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(subjects, 0);
}
}