mcp_trace_validator/checks/transport/
post.rs1use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
30
31use super::super::FindingSink;
32use crate::context::TraceContext;
33
34pub(in crate::checks) fn client_messages_use_post(
36 context: &TraceContext<'_>,
37 sink: &mut FindingSink,
38) {
39 let mut carrying: Option<(u64, &str)> = None;
40 for event in context.events() {
41 if event.transport != TransportKind::StreamableHttp
42 || event.direction != Direction::ClientToServer
43 {
44 continue;
45 }
46 match &event.body {
47 EventBody::Http { method, .. } => {
48 carrying = method.as_deref().map(|method| (event.seq, method));
49 }
50 EventBody::Message { .. } => {
51 let Some((request_seq, method)) = carrying else {
52 continue;
53 };
54 sink.examined();
55 if method != "POST" {
56 sink.push(
57 Some(event.seq),
58 format!(
59 "client JSON-RPC message rode an HTTP {method} request (seq \
60 {request_seq}); messages must be sent by POST"
61 ),
62 );
63 }
64 }
65 _ => {}
66 }
67 }
68}
69
70#[cfg(test)]
71#[allow(clippy::unwrap_used)]
72mod tests {
73 use mcp_conformance_core::trace::TraceEvent;
74
75 use crate::checks;
76 use crate::context::TraceContext;
77 use crate::reader::{Limits, parse_trace};
78
79 const CHECK: &str = "transport.client-messages-use-post";
80
81 fn outcome(lines: &[String]) -> (Vec<String>, u32) {
82 let document = lines.join("\n");
83 let events: Vec<TraceEvent> = parse_trace(&document, &Limits::default()).unwrap();
84 let context = TraceContext::new(&events);
85 let outcome = checks::find(CHECK).unwrap().run(&context);
86 (
87 outcome
88 .findings
89 .into_iter()
90 .map(|finding| finding.detail)
91 .collect(),
92 outcome.subjects,
93 )
94 }
95
96 fn http(seq: u64, method: Option<&str>) -> String {
97 let verb = method.map_or_else(String::new, |method| format!(r#""method":"{method}","#));
98 format!(
99 r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"http",{verb}"headers":{{}}}}"#
100 )
101 }
102
103 fn message(seq: u64) -> String {
104 format!(
105 r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"method":"tools/list"}}}}"#
106 )
107 }
108
109 #[test]
110 fn a_message_carried_by_a_post_passes() {
111 let (findings, subjects) = outcome(&[http(0, Some("POST")), message(1)]);
112 assert!(findings.is_empty(), "{findings:?}");
113 assert_eq!(subjects, 1);
114 }
115
116 #[test]
117 fn a_message_carried_by_another_verb_is_a_violation() {
118 let (findings, _) = outcome(&[http(0, Some("PUT")), message(1)]);
119 assert_eq!(findings.len(), 1, "{findings:?}");
120 assert!(findings[0].contains("PUT"), "{findings:?}");
121 assert!(findings[0].contains("seq 0"), "{findings:?}");
122 }
123
124 #[test]
125 fn a_teardown_delete_carries_no_message_and_is_not_judged() {
126 let (findings, subjects) =
129 outcome(&[http(0, Some("POST")), message(1), http(2, Some("DELETE"))]);
130 assert!(findings.is_empty(), "{findings:?}");
131 assert_eq!(subjects, 1, "only the POST's message was a subject");
132 }
133
134 #[test]
135 fn a_stream_opening_get_is_not_credited_with_the_previous_posts_message() {
136 let (findings, subjects) = outcome(&[
139 http(0, Some("POST")),
140 message(1),
141 http(2, Some("GET")),
142 message(3),
143 ]);
144 assert_eq!(findings.len(), 1, "{findings:?}");
145 assert!(findings[0].contains("GET"), "{findings:?}");
146 assert_eq!(subjects, 2);
147 }
148
149 #[test]
150 fn messages_with_no_recorded_request_are_not_judged() {
151 let (findings, subjects) = outcome(&[message(0), message(1)]);
154 assert!(findings.is_empty(), "{findings:?}");
155 assert_eq!(subjects, 0);
156
157 let (findings, subjects) = outcome(&[http(0, None), message(1)]);
159 assert!(findings.is_empty(), "{findings:?}");
160 assert_eq!(subjects, 0);
161 }
162
163 #[test]
164 fn a_servers_reply_is_not_the_clients_message() {
165 let reply = r#"{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}}"#.to_owned();
171 let (findings, subjects) = outcome(&[http(0, Some("POST")), message(1), reply]);
172 assert!(findings.is_empty(), "{findings:?}");
173 assert_eq!(subjects, 1, "only the client's message rode the POST");
174 }
175
176 #[test]
177 fn stdio_messages_are_out_of_scope() {
178 let stdio = [
179 r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#.to_owned(),
180 ];
181 let (findings, subjects) = outcome(&stdio);
182 assert!(findings.is_empty(), "{findings:?}");
183 assert_eq!(subjects, 0);
184 }
185}