mcp_trace_validator/checks/transport/
accept.rs1use std::collections::BTreeMap;
42
43use mcp_conformance_core::trace::{Direction, EventBody};
44
45use super::super::FindingSink;
46use crate::context::TraceContext;
47
48fn client_requests<'a>(
50 context: &TraceContext<'a>,
51) -> impl Iterator<Item = (u64, &'a str, &'a BTreeMap<String, String>)> {
52 context
53 .events()
54 .iter()
55 .filter(|event| event.direction == Direction::ClientToServer)
56 .filter_map(|event| match &event.body {
57 EventBody::Http {
58 method: Some(method),
59 headers,
60 ..
61 } => Some((event.seq, method.as_str(), headers)),
62 _ => None,
63 })
64}
65
66fn offers(accept: &str, media: &str) -> bool {
74 accept.to_ascii_lowercase().contains(media)
75}
76
77pub(in crate::checks) fn client_post_accept_header(
80 context: &TraceContext<'_>,
81 sink: &mut FindingSink,
82) {
83 for (seq, method, headers) in client_requests(context) {
84 if method != "POST" {
85 continue;
86 }
87 sink.examined();
88 let Some(accept) = headers.get("accept") else {
89 sink.push(
90 Some(seq),
91 "client HTTP POST has no Accept header; it must list both \
92 application/json and text/event-stream"
93 .to_owned(),
94 );
95 continue;
96 };
97 let missing: Vec<&str> = ["application/json", "text/event-stream"]
98 .into_iter()
99 .filter(|media| !offers(accept, media))
100 .collect();
101 if !missing.is_empty() {
102 sink.push(
103 Some(seq),
104 format!(
105 "client POST Accept header {accept:?} does not list {}; \
106 a POST must offer both application/json and text/event-stream",
107 missing.join(" or ")
108 ),
109 );
110 }
111 }
112}
113
114pub(in crate::checks) fn client_get_accept_header(
117 context: &TraceContext<'_>,
118 sink: &mut FindingSink,
119) {
120 for (seq, method, headers) in client_requests(context) {
121 if method != "GET" {
122 continue;
123 }
124 sink.examined();
125 match headers.get("accept") {
126 None => sink.push(
127 Some(seq),
128 "client HTTP GET has no Accept header; a GET to the MCP endpoint \
129 must list text/event-stream"
130 .to_owned(),
131 ),
132 Some(accept) if !offers(accept, "text/event-stream") => sink.push(
133 Some(seq),
134 format!("client GET Accept header {accept:?} does not list text/event-stream"),
135 ),
136 Some(_) => {}
137 }
138 }
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used)]
143mod tests {
144 use mcp_conformance_core::trace::TraceEvent;
145
146 use crate::checks;
147 use crate::context::TraceContext;
148 use crate::reader::{Limits, parse_trace};
149
150 fn request(seq: u64, method: &str, headers: &str) -> String {
152 format!(
153 r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"{method}","headers":{headers}}}"#
154 )
155 }
156
157 fn run(check: &str, lines: &[String]) -> Vec<String> {
158 let document = lines.join("\n");
159 let events: Vec<TraceEvent> = parse_trace(&document, &Limits::default()).unwrap();
160 let context = TraceContext::new(&events);
161 checks::find(check)
162 .unwrap()
163 .run(&context)
164 .findings
165 .into_iter()
166 .map(|finding| finding.detail)
167 .collect()
168 }
169
170 fn examined(check: &str, lines: &[String]) -> u32 {
173 let document = lines.join("\n");
174 let events: Vec<TraceEvent> = parse_trace(&document, &Limits::default()).unwrap();
175 let context = TraceContext::new(&events);
176 checks::find(check).unwrap().run(&context).subjects
177 }
178
179 const BOTH: &str = r#"{"accept":"application/json, text/event-stream"}"#;
180
181 #[test]
182 fn a_post_must_offer_both_media_types() {
183 let full = [request(0, "POST", BOTH)];
184 assert!(run("transport.client-post-accept-header", &full).is_empty());
185
186 let stream_only = [request(0, "POST", r#"{"accept":"text/event-stream"}"#)];
189 let findings = run("transport.client-post-accept-header", &stream_only);
190 assert_eq!(findings.len(), 1, "{findings:?}");
191 assert!(findings[0].contains("application/json"), "{findings:?}");
192
193 let json_only = [request(0, "POST", r#"{"accept":"application/json"}"#)];
194 let findings = run("transport.client-post-accept-header", &json_only);
195 assert_eq!(findings.len(), 1, "{findings:?}");
196 assert!(findings[0].contains("text/event-stream"), "{findings:?}");
197
198 let none = [request(0, "POST", "{}")];
199 let findings = run("transport.client-post-accept-header", &none);
200 assert_eq!(findings.len(), 1, "{findings:?}");
201 assert!(findings[0].contains("no Accept header"), "{findings:?}");
202 }
203
204 #[test]
205 fn a_get_must_offer_the_event_stream_only() {
206 let stream_only = [request(0, "GET", r#"{"accept":"text/event-stream"}"#)];
207 assert!(run("transport.client-get-accept-header", &stream_only).is_empty());
208
209 let json_only = [request(0, "GET", r#"{"accept":"application/json"}"#)];
210 let findings = run("transport.client-get-accept-header", &json_only);
211 assert_eq!(findings.len(), 1, "{findings:?}");
212 assert!(findings[0].contains("text/event-stream"), "{findings:?}");
213
214 let none = [request(0, "GET", "{}")];
215 let findings = run("transport.client-get-accept-header", &none);
216 assert_eq!(findings.len(), 1, "{findings:?}");
217 assert!(findings[0].contains("no Accept header"), "{findings:?}");
218 }
219
220 #[test]
221 fn a_session_teardown_delete_owes_no_accept_header() {
222 let teardown = [
225 request(0, "POST", BOTH),
226 request(1, "DELETE", r#"{"accept":"*/*","mcp-session-id":"abc123"}"#),
227 ];
228 assert!(
229 run("transport.client-post-accept-header", &teardown).is_empty(),
230 "a DELETE is not a POST"
231 );
232 assert!(
233 run("transport.client-get-accept-header", &teardown).is_empty(),
234 "a DELETE is not a GET"
235 );
236
237 let bare = [request(0, "DELETE", r#"{"mcp-session-id":"abc123"}"#)];
239 assert!(run("transport.client-post-accept-header", &bare).is_empty());
240 assert!(run("transport.client-get-accept-header", &bare).is_empty());
241 }
242
243 #[test]
244 fn a_request_whose_method_was_not_recorded_is_not_judged() {
245 let methodless = [
248 r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{"accept":"*/*"}}"#
249 .to_owned(),
250 ];
251 for check in [
252 "transport.client-post-accept-header",
253 "transport.client-get-accept-header",
254 ] {
255 assert!(run(check, &methodless).is_empty(), "{check}");
256 assert_eq!(examined(check, &methodless), 0, "{check}");
257 }
258 }
259
260 #[test]
261 fn method_matching_survives_a_lowercasing_capturer() {
262 let lowercased = [request(0, "post", r#"{"accept":"application/json"}"#)];
263 let findings = run("transport.client-post-accept-header", &lowercased);
264 assert_eq!(findings.len(), 1, "{findings:?}");
265 }
266
267 #[test]
268 fn accept_matching_ignores_order_case_and_parameters() {
269 let fussy = [request(
270 0,
271 "POST",
272 r#"{"accept":"TEXT/EVENT-STREAM;q=0.9, Application/JSON;q=1.0"}"#,
273 )];
274 assert!(run("transport.client-post-accept-header", &fussy).is_empty());
275 }
276}