Skip to main content

mcp_trace_validator/checks/transport/
accept.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The client `Accept`-header checks, one per request method.
5//!
6//! Streamable HTTP gives the client three request forms and binds a different
7//! obligation to each:
8//!
9//! - **POST** carries messages, and its `Accept` MUST list *both*
10//!   `application/json` and `text/event-stream` (`TRAN-025` at `2025-11-25`,
11//!   `TRAN-057` at `2026-07-28`).
12//! - **GET** opens a standalone stream, and its `Accept` MUST list
13//!   `text/event-stream` (`TRAN-039`; `2026-07-28` removes the form).
14//! - **DELETE** terminates a session and carries **no** `Accept` obligation at
15//!   all — the clause that defines it (`basic/transports` §Session Management
16//!   item 5) says only that the request names the session.
17//!
18//! Until 2026-08-20 one check enforced the *intersection* of the first two
19//! obligations across the *union* of all three forms, because the recorded
20//! event carried no method. That was wrong in both directions at once, and
21//! both were reproduced against this repository's own reference host driving
22//! its own reference server over HTTP:
23//!
24//! - The conforming session-teardown `DELETE` — `Accept: */*`, exactly what a
25//!   client that owes nothing sends — was reported as failing `TRAN-025` *and*
26//!   `TRAN-039`, two MUST-level failures against a client that had violated
27//!   nothing.
28//! - A client omitting `application/json` from a POST's `Accept`, which is the
29//!   verbatim violation `TRAN-025` names, was reported `pass`, because the
30//!   intersection only demanded the other media type.
31//!
32//! The method is now recorded ([`EventBody::Http::method`]) and each clause is
33//! judged against exactly the requests it binds. An event whose method the
34//! capture did not record is examined by neither: a recording that cannot tell
35//! a POST from a DELETE can neither evidence a POST-only MUST nor convict on
36//! one, so the clause reports *not observed* — the outcome this validator uses
37//! everywhere else for a subject the trace does not carry.
38//!
39//! [`EventBody::Http::method`]: mcp_conformance_core::trace::EventBody
40
41use std::collections::BTreeMap;
42
43use mcp_conformance_core::trace::{Direction, EventBody};
44
45use super::super::FindingSink;
46use crate::context::TraceContext;
47
48/// The client requests whose method the capture recorded, in trace order.
49fn 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
66/// Whether an `Accept` field value offers `media`, case-insensitively.
67///
68/// Field values are matched by substring rather than parsed: the media types
69/// at issue contain no character that could appear as a parameter value in a
70/// conforming header, so a substring hit cannot be a false one, and `q`
71/// parameters, ordering, and whitespace are all irrelevant to whether the type
72/// was listed.
73fn offers(accept: &str, media: &str) -> bool {
74    accept.to_ascii_lowercase().contains(media)
75}
76
77/// `TRAN-025` (`2025-11-25`) / `TRAN-057` (`2026-07-28`): a client POST must
78/// `Accept` both `application/json` and `text/event-stream`.
79pub(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
114/// `TRAN-039`: a client GET opening a standalone stream must `Accept`
115/// `text/event-stream`.
116pub(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    /// One client HTTP request event, with whatever method and headers.
151    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    /// Whether the check examined any subject at all, which is what separates
171    /// `not observed` from `pass` in the report.
172    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        // The half the old single check could not see: `text/event-stream`
187        // alone satisfied it, though TRAN-025 names both.
188        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        // The exact exchange the reference host sends at teardown, which both
223        // clauses used to fail: reqwest's default `Accept: */*` on a DELETE.
224        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        // A DELETE with no Accept header at all is equally conforming.
238        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        // No method: neither check may convict, and neither may claim to have
246        // examined anything — the clause reports `not observed`, not `pass`.
247        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}