Skip to main content

mcp_trace_validator/checks/draft/transport/
headers.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The `2026-07-28` request-header clauses a *client* owns: which headers a POST
5//! carries, that their values are encoded safely, and that a tool's
6//! `x-mcp-header` annotations are usable at all.
7//!
8//! Every POST clause here is scoped to POSTs carrying a JSON-RPC *request*.
9//! That is the revision's own boundary — "header requirements for notification
10//! POSTs are not defined by this revision" (`#sending-messages`) — not a
11//! convenience. [`x_mcp_header_name_valid`] is the one exception, and only
12//! because it judges a tool definition rather than a POST.
13
14use std::collections::BTreeSet;
15
16use super::super::super::FindingSink;
17use super::{
18    Designation, Match, Post, compare, designations, designations_by_tool, header_safe,
19    is_miscased_sentinel, mirrors, posts, tool_definitions,
20};
21use crate::context::TraceContext;
22
23#[cfg(test)]
24mod tests;
25
26/// RFC 9110 §5.1 `tchar`, the punctuation half.
27const TCHAR: &[u8] = b"!#$%&'*+-.^_`|~";
28
29/// The JSON Schema types an `x-mcp-header` annotation may sit on.
30const PRIMITIVE_TYPES: &[&str] = &["integer", "string", "boolean"];
31
32/// `TRAN-071`: every POST request carries an `MCP-Protocol-Version` header.
33///
34/// Deliberately not the `2025-11-25` check of the same purpose: that one skips
35/// everything up to the negotiated `initialize` result, so with the handshake
36/// gone it would pass every trace without inspecting a single request.
37pub(in crate::checks) fn protocol_version_header_present(
38    context: &TraceContext<'_>,
39    sink: &mut FindingSink,
40) {
41    for post in posts(context) {
42        if !post.is_request() {
43            continue; // Notification POSTs carry no header requirements here.
44        }
45        sink.examined();
46        if !post.headers.contains_key("mcp-protocol-version") {
47            sink.push(
48                Some(post.seq),
49                "client POST lacks the MCP-Protocol-Version header".to_owned(),
50            );
51        }
52    }
53}
54
55/// `TRAN-072`: the header's value matches the body's `_meta` protocol version.
56pub(in crate::checks) fn protocol_version_header_matches_body(
57    context: &TraceContext<'_>,
58    sink: &mut FindingSink,
59) {
60    for post in posts(context) {
61        if !post.is_request() {
62            continue;
63        }
64        // Absence is TRAN-071's finding, not a mismatch; a body that states no
65        // version is BASE-030's.
66        let (Some(header), Some(body)) = (
67            post.headers.get("mcp-protocol-version"),
68            post.body_protocol_version(),
69        ) else {
70            continue;
71        };
72        sink.examined();
73        if header != body {
74            sink.push(
75                Some(post.seq),
76                format!(
77                    "MCP-Protocol-Version header is {header:?} but the body's \
78                     `_meta` protocol version is {body:?}"
79                ),
80            );
81        }
82    }
83}
84
85/// `TRAN-058`: the standard request metadata headers accompany each POST request.
86///
87/// The Standard Request Headers table defines each header *by its source field*
88/// (`Mcp-Method` from `method`, `Mcp-Name` from `params.name` or `params.uri`),
89/// so a header carrying something else is not the required header — the same
90/// reading applied to both, and the reason a mismatch is reported here rather
91/// than only as the server-side failure to reject it (TRAN-097/TRAN-100).
92pub(in crate::checks) fn request_metadata_headers(
93    context: &TraceContext<'_>,
94    sink: &mut FindingSink,
95) {
96    let designated = designations_by_tool(context);
97    for post in posts(context) {
98        if !post.is_request() {
99            continue;
100        }
101        for mirror in mirrors(&post, &designated) {
102            if mirror.header.starts_with("mcp-param-") {
103                continue; // custom headers are TRAN-079's
104            }
105            sink.examined();
106            let Some(sent) = post.headers.get(&mirror.header) else {
107                sink.push(
108                    Some(post.seq),
109                    format!(
110                        "POST for `{}` lacks the required `{}` header",
111                        post.method().unwrap_or_default(),
112                        mirror.label
113                    ),
114                );
115                continue;
116            };
117            if compare(sent, &mirror.value) == Match::Mismatch {
118                sink.push(
119                    Some(post.seq),
120                    format!(
121                        "`{}` header is {sent:?} but `{}` is {:?}",
122                        mirror.label, mirror.source, mirror.value
123                    ),
124                );
125            }
126        }
127    }
128}
129
130/// The headers of `post` whose values the Base64 sentinel may carry.
131fn encodable_headers<'a>(post: &Post<'a>) -> impl Iterator<Item = (&'a String, &'a String)> {
132    post.headers
133        .iter()
134        .filter(|(name, _)| *name == "mcp-name" || name.starts_with("mcp-param-"))
135}
136
137/// `TRAN-077`/`TRAN-086`/`TRAN-087`: a value that cannot be carried plainly is
138/// carried Base64-encoded.
139///
140/// Deliberately narrower than "everything the Value Encoding section says": the
141/// marker-case rule (TRAN-089) and the sentinel-pattern rule (TRAN-092) are
142/// separate clauses with separate checks, because a requirement judged by a
143/// check that bundles its neighbours' rules cannot report which rule it broke.
144pub(in crate::checks) fn header_value_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
145    for post in posts(context) {
146        if !post.is_request() {
147            continue;
148        }
149        for (name, value) in encodable_headers(&post) {
150            // Miscased markers are TRAN-089's finding; reporting them here too
151            // would blame the wrong clause. A *correctly* spelled sentinel needs
152            // no exemption — its markers and Base64 payload are visible ASCII,
153            // so it is header-safe on its own, and a sentinel whose payload is
154            // not (`=?base64?café?=`) is exactly the defect this clause names.
155            if is_miscased_sentinel(value) {
156                continue;
157            }
158            sink.examined();
159            if !header_safe(value) {
160                sink.push(
161                    Some(post.seq),
162                    format!(
163                        "header `{name}` carries {value:?} unencoded; a value that is not \
164                         safely representable in ASCII must use the Base64 sentinel"
165                    ),
166                );
167            }
168        }
169    }
170}
171
172/// `TRAN-089`: the sentinel markers appear exactly as shown, in lowercase.
173pub(in crate::checks) fn sentinel_marker_case(context: &TraceContext<'_>, sink: &mut FindingSink) {
174    for post in posts(context) {
175        if !post.is_request() {
176            continue;
177        }
178        for (name, value) in encodable_headers(&post) {
179            sink.examined();
180            if is_miscased_sentinel(value) {
181                sink.push(
182                    Some(post.seq),
183                    format!(
184                        "header `{name}` carries {value:?}, whose Base64 sentinel markers \
185                         are miscased; they are case-sensitive and must be lowercase"
186                    ),
187                );
188            }
189        }
190    }
191}
192
193/// `TRAN-092`: a plain value matching the sentinel pattern is encoded too.
194///
195/// The trace shows this directly: the header repeats the body value byte for
196/// byte, which encoding could never produce for a value already shaped like the
197/// sentinel.
198pub(in crate::checks) fn sentinel_pattern_encoded(
199    context: &TraceContext<'_>,
200    sink: &mut FindingSink,
201) {
202    let designated = designations_by_tool(context);
203    for post in posts(context) {
204        if !post.is_request() {
205            continue;
206        }
207        for mirror in mirrors(&post, &designated) {
208            if !mirror.encodable {
209                continue;
210            }
211            let Some(sent) = post.headers.get(&mirror.header) else {
212                continue;
213            };
214            sink.examined();
215            if compare(sent, &mirror.value) == Match::UnencodedSentinel {
216                sink.push(
217                    Some(post.seq),
218                    format!(
219                        "`{}` carries {sent:?} verbatim; a value matching the sentinel \
220                         pattern must itself be Base64-encoded to stay unambiguous",
221                        mirror.label
222                    ),
223                );
224            }
225        }
226    }
227}
228
229/// `TRAN-079`: designated tool parameters are mirrored into `Mcp-Param-*`.
230///
231/// Judged against the request itself: a `tools/call` supplying an argument the
232/// server designated must carry the matching header. The designation lives in
233/// the tool definition, so this binds only where the trace also carried the tool
234/// list that declared it.
235pub(in crate::checks) fn x_mcp_header_mirrored(context: &TraceContext<'_>, sink: &mut FindingSink) {
236    let designated = designations_by_tool(context);
237    if designated.is_empty() {
238        return;
239    }
240    for post in posts(context) {
241        if !post.is_request() {
242            continue;
243        }
244        for mirror in mirrors(&post, &designated) {
245            if !mirror.header.starts_with("mcp-param-") {
246                continue; // The standard headers are TRAN-058's.
247            }
248            sink.examined();
249            if !post.headers.contains_key(&mirror.header) {
250                sink.push(
251                    Some(post.seq),
252                    format!(
253                        "`tools/call` supplies `{}`, which the tool designates for header \
254                         `{}`, but the POST does not carry it",
255                        mirror.source, mirror.label
256                    ),
257                );
258            }
259        }
260    }
261}
262
263/// `TRAN-080`: an `x-mcp-header` annotation is usable as a header name.
264///
265/// All five constraints the clause states are judgeable from a tool definition
266/// alone, and all five are checked: non-empty, field-name token syntax, no
267/// control characters (which token syntax excludes outright), case-insensitive
268/// uniqueness within one `inputSchema`, and primitive-typed properties only.
269/// The neighbouring bullets about static reachability belong to TRAN-081 and
270/// TRAN-082, which the registry excludes — judging those needs a schema engine,
271/// not a session.
272pub(in crate::checks) fn x_mcp_header_name_valid(
273    context: &TraceContext<'_>,
274    sink: &mut FindingSink,
275) {
276    for (seq, tool) in tool_definitions(context) {
277        let Some(schema) = tool.get("inputSchema") else {
278            continue;
279        };
280        let mut seen: BTreeSet<String> = BTreeSet::new();
281        for designation in designations(schema) {
282            sink.examined();
283            let duplicate = !seen.insert(designation.name.to_ascii_lowercase());
284            for reason in annotation_faults(&designation, duplicate) {
285                sink.push(
286                    Some(seq),
287                    format!(
288                        "property `{}` designates `x-mcp-header` {:?}, which {reason}",
289                        designation.path.join("."),
290                        designation.name
291                    ),
292                );
293            }
294        }
295    }
296}
297
298/// Every way `designation` breaks TRAN-080, as finding-ready clauses.
299fn annotation_faults(designation: &Designation, duplicate: bool) -> Vec<String> {
300    let mut faults = Vec::new();
301    if designation.name.is_empty() {
302        faults.push("is empty".to_owned());
303    } else if !designation
304        .name
305        .bytes()
306        .all(|byte| byte.is_ascii_alphanumeric() || TCHAR.contains(&byte))
307    {
308        faults.push("is not an HTTP field-name token (`1*tchar`, RFC 9110 §5.1)".to_owned());
309    }
310    if duplicate {
311        faults.push(
312            "repeats an earlier `x-mcp-header` in this `inputSchema`; the values must be \
313             case-insensitively unique"
314                .to_owned(),
315        );
316    }
317    // An untyped property states nothing to judge; only a stated non-primitive
318    // type is a violation, which is why `number` is caught and absence is not.
319    if let Some(declared) = designation.declared_type.as_deref()
320        && !PRIMITIVE_TYPES.contains(&declared)
321    {
322        faults.push(format!(
323            "annotates a `{declared}` property; only integer, string and boolean \
324             parameters may carry one"
325        ));
326    }
327    faults
328}