Skip to main content

protocol_droid/application/mcp/
param_headers.rs

1//! `Mcp-Param-{Name}`: tool arguments a client mirrors into HTTP headers.
2//!
3//! `2026-07-28` (SEP-2243). A tool's `inputSchema` may mark a property with
4//! `x-mcp-header: "Region"`, and a client calling that tool over Streamable
5//! HTTP must then send the argument's value as `Mcp-Param-Region`. A server
6//! that reads the body must check the two agree — an intermediary may have
7//! routed on the header while this server is about to act on the body — and
8//! refuse a disagreement with `400` and `-32020 HeaderMismatch`.
9//!
10//! The rules here are the transport specification's, quoted where they decide
11//! something:
12//! <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#custom-headers-from-tool-parameters>
13//!
14//! - Only properties *statically reachable* through `properties` keys count;
15//!   an annotation under `items`, a composition keyword, a conditional or a
16//!   `$ref` makes the annotation invalid, and an invalid one is not enforced.
17//! - Only `string`, `integer` and `boolean` parameters may be annotated.
18//! - "Parameter value is `null`" and "parameter not in arguments": the server
19//!   "MUST NOT expect the header". "Client omits header but value is in body":
20//!   the server "MUST reject the request".
21//! - A value wrapped as `=?base64?…?=` is decoded before comparison, and one
22//!   whose payload is not valid Base64 is refused; a value without the exact
23//!   wrapper is literal.
24//! - Integers compare numerically, so `42.0` and `42` agree.
25
26use base64::Engine as _;
27use serde_json::Value;
28
29/// One annotated parameter: where its value lives and which header carries it.
30struct Annotated {
31    path: Vec<String>,
32    header: String,
33    kind: Kind,
34}
35
36#[derive(Clone, Copy)]
37enum Kind {
38    String,
39    Integer,
40    Boolean,
41}
42
43/// Whether `x-mcp-header`'s value is a legal header-name token (RFC 9110 `tchar`).
44fn is_token(name: &str) -> bool {
45    !name.is_empty()
46        && name.bytes().all(|byte| {
47            byte.is_ascii_alphanumeric()
48                || matches!(
49                    byte,
50                    b'!' | b'#'
51                        | b'$'
52                        | b'%'
53                        | b'&'
54                        | b'\''
55                        | b'*'
56                        | b'+'
57                        | b'-'
58                        | b'.'
59                        | b'^'
60                        | b'_'
61                        | b'`'
62                        | b'|'
63                        | b'~'
64                )
65        })
66}
67
68/// Whether `schema` carries any `x-mcp-header` annotation the transport
69/// specification forbids.
70///
71/// For a client: "Clients using the Streamable HTTP transport MUST reject tool
72/// definitions where any `x-mcp-header` value violates these constraints",
73/// which means leaving the tool out of `tools/list`. An annotation is invalid
74/// when its name is not a header token, when it sits on a type other than
75/// `string`, `integer` or `boolean`, when it is reached through anything but
76/// `properties`, or when two annotations name the same header.
77#[must_use]
78pub fn has_invalid_annotation(schema: &Value) -> bool {
79    let reachable = annotations(schema).len();
80    let mut everywhere = 0;
81    count_anywhere(schema, &mut everywhere);
82    reachable != everywhere
83}
84
85fn count_anywhere(value: &Value, count: &mut usize) {
86    match value {
87        Value::Object(members) => {
88            if members.get("x-mcp-header").is_some() {
89                *count += 1;
90            }
91            for member in members.values() {
92                count_anywhere(member, count);
93            }
94        }
95        Value::Array(items) => {
96            for item in items {
97                count_anywhere(item, count);
98            }
99        }
100        _ => {}
101    }
102}
103
104/// The `Mcp-Param-*` headers a client must send for `arguments`.
105///
106/// One per annotated parameter with a non-null value, encoded per the value
107/// rules: strings as-is, integers in decimal, booleans as `true`/`false`, and
108/// anything that is not safely a plain header value in the Base64 sentinel.
109#[must_use]
110pub fn headers_for(schema: &Value, arguments: &Value) -> Vec<(String, String)> {
111    let mut out = Vec::new();
112    for annotated in annotations(schema) {
113        let mut value = Some(arguments);
114        for key in &annotated.path {
115            value = value.and_then(|current| current.get(key));
116        }
117        let text = match (annotated.kind, value) {
118            (Kind::String, Some(Value::String(text))) => text.clone(),
119            (Kind::Integer, Some(Value::Number(number))) => number
120                .as_i64()
121                .map_or_else(|| number.to_string(), |integer| integer.to_string()),
122            (Kind::Boolean, Some(Value::Bool(flag))) => flag.to_string(),
123            _ => continue,
124        };
125        out.push((format!("Mcp-Param-{}", annotated.header), encode(&text)));
126    }
127    out
128}
129
130/// Encodes a value for a header that admits the Base64 sentinel.
131///
132/// Plain when every character is visible ASCII or an inner space or tab;
133/// otherwise — and always when the plain text would itself look like the
134/// sentinel — `=?base64?{standard Base64 of the UTF-8}?=`.
135#[must_use]
136pub fn encode(value: &str) -> String {
137    let printable = value
138        .bytes()
139        .all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte));
140    let padded = value.starts_with([' ', '\t']) || value.ends_with([' ', '\t']);
141    let sentinel = value.starts_with("=?base64?") && value.ends_with("?=");
142    let safe = printable && !padded && !sentinel;
143    if safe {
144        return value.to_owned();
145    }
146    format!(
147        "=?base64?{}?=",
148        base64::engine::general_purpose::STANDARD.encode(value.as_bytes())
149    )
150}
151
152/// Every valid annotation reachable from `schema` through `properties` alone.
153fn annotations(schema: &Value) -> Vec<Annotated> {
154    let mut found = Vec::new();
155    walk(schema, &mut Vec::new(), &mut found);
156    // Case-insensitive uniqueness is a validity rule: a schema that names the
157    // same header twice has no well-defined header to compare, so neither is
158    // enforced.
159    let mut names: Vec<String> = found
160        .iter()
161        .map(|a| a.header.to_ascii_lowercase())
162        .collect();
163    names.sort();
164    let duplicated: Vec<String> = names
165        .windows(2)
166        .filter(|pair| pair.first() == pair.get(1))
167        .filter_map(|pair| pair.first().cloned())
168        .collect();
169    found.retain(|annotated| !duplicated.contains(&annotated.header.to_ascii_lowercase()));
170    found
171}
172
173fn walk(schema: &Value, path: &mut Vec<String>, found: &mut Vec<Annotated>) {
174    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
175        return;
176    };
177    for (key, property) in properties {
178        path.push(key.clone());
179        if let Some(header) = property.get("x-mcp-header").and_then(Value::as_str) {
180            let kind = match property.get("type").and_then(Value::as_str) {
181                Some("string") => Some(Kind::String),
182                Some("integer") => Some(Kind::Integer),
183                Some("boolean") => Some(Kind::Boolean),
184                _ => None,
185            };
186            if let Some(kind) = kind
187                && is_token(header)
188            {
189                found.push(Annotated {
190                    path: path.clone(),
191                    header: header.to_owned(),
192                    kind,
193                });
194            }
195        }
196        walk(property, path, found);
197        path.pop();
198    }
199}
200
201/// Decodes a header value per the sentinel rule, or `None` if it is invalid.
202pub fn decode(value: &str) -> Option<String> {
203    let Some(encoded) = value
204        .strip_prefix("=?base64?")
205        .and_then(|rest| rest.strip_suffix("?="))
206    else {
207        return Some(value.to_owned());
208    };
209    let bytes = base64::engine::general_purpose::STANDARD
210        .decode(encoded)
211        .ok()?;
212    String::from_utf8(bytes).ok()
213}
214
215/// Whether a decoded header value agrees with the body's value.
216fn agrees(kind: Kind, header: &str, body: &Value) -> bool {
217    match kind {
218        Kind::String => body.as_str() == Some(header),
219        Kind::Boolean => {
220            body.as_bool()
221                .map(|flag| if flag { "true" } else { "false" })
222                == Some(header)
223        }
224        Kind::Integer => {
225            let parsed = header.trim().parse::<f64>().ok();
226            let body = body.as_f64();
227            matches!((parsed, body), (Some(left), Some(right)) if (left - right).abs() < f64::EPSILON)
228        }
229    }
230}
231
232/// Checks every annotated argument against the `Mcp-Param-*` headers.
233///
234/// `headers` holds every `mcp-param-*` header, names lowercased. Returns the
235/// offending header name on the first disagreement.
236pub fn check(
237    schema: &Value,
238    arguments: &Value,
239    headers: &[(String, String)],
240) -> Result<(), String> {
241    for annotated in annotations(schema) {
242        let mut value = Some(arguments);
243        for key in &annotated.path {
244            value = value.and_then(|current| current.get(key));
245        }
246        let name = format!("Mcp-Param-{}", annotated.header);
247        let wanted = name.to_ascii_lowercase();
248        let sent = headers
249            .iter()
250            .find(|(header, _)| *header == wanted)
251            .map(|(_, value)| value.as_str());
252        match (value.filter(|value| !value.is_null()), sent) {
253            (None, _) => {}
254            (Some(_), None) => return Err(name),
255            (Some(body), Some(raw)) => {
256                let decoded = decode(raw).ok_or_else(|| name.clone())?;
257                if !agrees(annotated.kind, &decoded, body) {
258                    return Err(name);
259                }
260            }
261        }
262    }
263    Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268    use serde_json::json;
269
270    use super::check;
271
272    fn schema() -> serde_json::Value {
273        json!({
274            "type": "object",
275            "properties": {
276                "region": { "type": "string", "x-mcp-header": "Region" },
277                "limit": { "type": "integer", "x-mcp-header": "Limit" },
278                "nested": { "type": "object", "properties": {
279                    "flag": { "type": "boolean", "x-mcp-header": "Flag" }
280                }},
281                "listed": { "type": "array", "items": { "type": "string", "x-mcp-header": "Item" } },
282                "ratio": { "type": "number", "x-mcp-header": "Ratio" }
283            }
284        })
285    }
286
287    fn headers(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
288        pairs
289            .iter()
290            .map(|(name, value)| (name.to_ascii_lowercase(), (*value).to_owned()))
291            .collect()
292    }
293
294    #[test]
295    fn matching_headers_pass() {
296        let arguments = json!({ "region": "us-west1", "limit": 42, "nested": { "flag": true } });
297        let sent = headers(&[
298            ("Mcp-Param-Region", "us-west1"),
299            ("Mcp-Param-Limit", "42.0"),
300            ("Mcp-Param-Flag", "true"),
301        ]);
302        assert_eq!(check(&schema(), &arguments, &sent), Ok(()));
303    }
304
305    #[test]
306    fn a_value_in_the_body_without_its_header_is_refused() {
307        let arguments = json!({ "region": "us-west1" });
308        assert!(check(&schema(), &arguments, &[]).is_err());
309    }
310
311    #[test]
312    fn an_absent_or_null_value_expects_no_header() {
313        assert_eq!(check(&schema(), &json!({ "region": null }), &[]), Ok(()));
314        assert_eq!(check(&schema(), &json!({}), &[]), Ok(()));
315    }
316
317    #[test]
318    fn base64_is_decoded_and_must_be_valid() {
319        let arguments = json!({ "region": "Hello" });
320        assert_eq!(
321            check(
322                &schema(),
323                &arguments,
324                &headers(&[("Mcp-Param-Region", "=?base64?SGVsbG8=?=")])
325            ),
326            Ok(())
327        );
328        assert!(
329            check(
330                &schema(),
331                &arguments,
332                &headers(&[("Mcp-Param-Region", "=?base64?SGVsbG8?=")])
333            )
334            .is_err()
335        );
336        assert!(
337            check(
338                &schema(),
339                &arguments,
340                &headers(&[("Mcp-Param-Region", "=?base64?SGVs!!!bG8=?=")])
341            )
342            .is_err()
343        );
344    }
345
346    #[test]
347    fn a_value_without_the_exact_wrapper_is_literal() {
348        let arguments = json!({ "region": "=?base64?SGVsbG8=" });
349        assert_eq!(
350            check(
351                &schema(),
352                &arguments,
353                &headers(&[("Mcp-Param-Region", "=?base64?SGVsbG8=")])
354            ),
355            Ok(())
356        );
357    }
358
359    #[test]
360    fn annotations_behind_items_or_on_numbers_are_not_enforced() {
361        let arguments = json!({ "listed": ["a"], "ratio": 0.5 });
362        assert_eq!(check(&schema(), &arguments, &[]), Ok(()));
363    }
364
365    #[test]
366    fn invalid_annotations_are_detected() {
367        assert!(
368            super::has_invalid_annotation(&schema()),
369            "items and number annotations are invalid"
370        );
371        let clean = json!({ "type": "object", "properties": {
372            "region": { "type": "string", "x-mcp-header": "Region" }
373        }});
374        assert!(!super::has_invalid_annotation(&clean));
375        let duplicated = json!({ "type": "object", "properties": {
376            "a": { "type": "string", "x-mcp-header": "Same" },
377            "b": { "type": "string", "x-mcp-header": "same" }
378        }});
379        assert!(super::has_invalid_annotation(&duplicated));
380    }
381
382    #[test]
383    fn the_encoding_examples_from_the_specification() {
384        assert_eq!(super::encode("us-west1"), "us-west1");
385        assert_eq!(
386            super::encode("Hello, 世界"),
387            "=?base64?SGVsbG8sIOS4lueVjA==?="
388        );
389        assert_eq!(super::encode(" padded "), "=?base64?IHBhZGRlZCA=?=");
390        assert_eq!(super::encode("line1\nline2"), "=?base64?bGluZTEKbGluZTI=?=");
391        assert_eq!(
392            super::encode("=?base64?literal?="),
393            "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="
394        );
395    }
396
397    #[test]
398    fn a_client_mirrors_what_the_server_checks() {
399        let arguments = json!({ "region": "Hello, 世界", "limit": 7, "nested": { "flag": false } });
400        let sent: Vec<(String, String)> = super::headers_for(&schema(), &arguments)
401            .into_iter()
402            .map(|(name, value)| (name.to_ascii_lowercase(), value))
403            .collect();
404        assert_eq!(sent.len(), 3);
405        assert_eq!(check(&schema(), &arguments, &sent), Ok(()));
406    }
407
408    #[test]
409    fn a_mismatch_is_refused() {
410        let arguments = json!({ "region": "us-west1" });
411        assert!(
412            check(
413                &schema(),
414                &arguments,
415                &headers(&[("Mcp-Param-Region", "eu-west1")])
416            )
417            .is_err()
418        );
419    }
420}