protocol_droid/application/mcp/
param_headers.rs1use base64::Engine as _;
27use serde_json::Value;
28
29struct 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
43fn 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#[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#[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#[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
152fn annotations(schema: &Value) -> Vec<Annotated> {
154 let mut found = Vec::new();
155 walk(schema, &mut Vec::new(), &mut found);
156 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
201pub 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
215fn 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
232pub 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}