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}