protocol_droid/domain/mcp/routing_headers.rs
1//! `Mcp-Method` and `Mcp-Name` — the standard routing headers.
2//!
3//! # The spelling, and where it came from
4//!
5//! `crates/protocol-droid-schema/spec/2026-07-28/CHANGELOG.md`, **Minor changes
6//! #4**: "Require standard MCP request headers (`Mcp-Method`, `Mcp-Name`) on
7//! Streamable HTTP POST requests, and add support for custom headers from tool
8//! parameters via `x-mcp-header` (SEP-2243)."
9//!
10//! Three facts follow from that sentence and each one shaped a decision here.
11//!
12//! **The headers are required, not optional, from `2026-07-28`.** So the gate
13//! is on whether the *negotiated profile* carries
14//! [`Feature::StandardRoutingHeaders`], which the feature matrix dates to
15//! `2026-07-28` — nothing in the `2025-11-25` or earlier changelogs mentions
16//! them, and `schema.json` for `2026-07-28` does not define them either. They
17//! are a transport concern, which is why they have no entry in the JSON schema
18//! and why [`RoutingHeaders::for_profile`] refuses to build a pair the peer
19//! was never offered.
20//!
21//! **They are required only on POST.** Not on the stream, not on a response. So
22//! this module has no opinion about a response's headers and a caller that wants
23//! to emit one has to add it; the type models the request side only.
24//!
25//! **They are request routing, not request content.** `Mcp-Method` carries the
26//! JSON-RPC method name — a value that already exists in the IR as
27//! [`MethodName`] — and `Mcp-Name` carries the name of the thing the method acts
28//! on. The two are validated together, because a `Mcp-Method` without a
29//! `Mcp-Name` addresses nothing and a `Mcp-Name` without a `Mcp-Method` is not
30//! attached to an operation.
31//!
32//! # On `Mcp-Param-*`
33//!
34//! There is no such header. It appears in no vendored revision, and the
35//! mechanism the specification actually describes for carrying a tool
36//! parameter into a header is the `x-mcp-header` JSON Schema keyword on a
37//! property schema — see the `inputSchema` description in
38//! `crates/protocol-droid-schema/spec/2026-07-28/schema.json`, which says a
39//! property schema "may carry an `x-mcp-header` annotation to mirror the
40//! argument value into an HTTP header on the Streamable HTTP transport". The
41//! extraction rules it refers to live in the transport specification, which this
42//! repository does not vendor, so there is nothing here to implement against
43//! and nothing is invented. The prefix stays out of the code until the rules
44//! can be read rather than guessed.
45//!
46//! # Why the values are typed and not two `String`s
47//!
48//! Both values reach a server before a body is parsed, and both decide what the
49//! body is going to be dispatched as. A `String` at that point is exactly the
50//! "validate, don't parse" failure the project rules name: the handler would
51//! re-derive the method name and re-check the shape on the way in. Constructing
52//! a [`RoutingTarget`] performs those checks once, and the header parse
53//! produces one directly.
54//!
55//! # Why the header *name* is compared case-insensitively
56//!
57//! Because RFC 9110 §5.1 makes field names case-insensitive, and `mcp-method`
58//! from a client is the same header as `Mcp-Method`. The *values* stay
59//! case-sensitive: a method name and a tool name are both case-sensitive in the
60//! IR, and lowercasing one to compare it would be a silent data change.
61
62use core::fmt;
63use std::sync::Arc;
64
65use thiserror::Error;
66
67use crate::domain::Feature;
68use crate::domain::feature::FeatureProfile;
69use crate::domain::ir::MethodName;
70use crate::domain::mcp::session_id::VISIBLE_ASCII_RANGE;
71
72/// The method header's name, exactly as the specification spells it.
73pub const MCP_METHOD_HEADER: &str = "Mcp-Method";
74
75/// The target-name header's name, exactly as the specification spells it.
76pub const MCP_NAME_HEADER: &str = "Mcp-Name";
77
78/// The name of a tool, resource or prompt.
79///
80/// Distinct from [`MethodName`] because the two are constrained differently: a
81/// method name is slash-separated and its shape is a JSON-RPC convention, while
82/// a target name is whatever the server chose to call the thing and is only
83/// required to survive a trip through an HTTP field-value.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
85pub struct RoutingName(Arc<str>);
86
87impl RoutingName {
88 /// A target name.
89 ///
90 /// # Errors
91 ///
92 /// [`RoutingHeaderError::EmptyName`] for a blank name, and
93 /// [`RoutingHeaderError::NonVisibleAscii`] naming the first byte outside
94 /// [`VISIBLE_ASCII_RANGE`].
95 pub fn new(name: &str) -> Result<Self, RoutingHeaderError> {
96 if name.is_empty() {
97 return Err(RoutingHeaderError::EmptyName);
98 }
99 if let Some((index, byte)) = name
100 .bytes()
101 .enumerate()
102 .find(|(_, byte)| !VISIBLE_ASCII_RANGE.contains(byte))
103 {
104 return Err(RoutingHeaderError::NonVisibleAscii { index, byte });
105 }
106 Ok(Self(Arc::from(name)))
107 }
108
109 /// The name.
110 #[must_use]
111 pub fn as_str(&self) -> &str {
112 &self.0
113 }
114}
115
116impl fmt::Display for RoutingName {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 formatter.write_str(&self.0)
119 }
120}
121
122/// The operation a request addresses, and the thing it addresses it against.
123///
124/// A pair rather than two loose strings because the specification requires the
125/// two together: a `Mcp-Method` on its own routes to an operation but names no
126/// target, and a tool call is the only method that is about one specific
127/// thing.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct RoutingTarget {
130 method: MethodName,
131 name: RoutingName,
132}
133
134impl RoutingTarget {
135 /// Pairs a method with the thing it acts on.
136 #[must_use]
137 pub const fn new(method: MethodName, name: RoutingName) -> Self {
138 Self { method, name }
139 }
140
141 /// The operation.
142 #[must_use]
143 pub const fn method(&self) -> &MethodName {
144 &self.method
145 }
146
147 /// The thing being operated on.
148 #[must_use]
149 pub const fn name(&self) -> &RoutingName {
150 &self.name
151 }
152}
153
154/// A validated `Mcp-Method`/`Mcp-Name` pair, proven admissible for one session.
155///
156/// The profile is not stored. It is checked once, at construction, and the fact
157/// that construction succeeded *is* the proof — a type that could be built
158/// without a profile would be a way to skip the gate, and a type that carried
159/// the profile would let a later caller re-check a stale answer.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct RoutingHeaders(RoutingTarget);
162
163impl RoutingHeaders {
164 /// Builds the pair, but only for a session that was offered the feature.
165 ///
166 /// # Errors
167 ///
168 /// [`RoutingHeaderError::FeatureUnavailable`] when the negotiated profile
169 /// does not carry [`Feature::StandardRoutingHeaders`] — that is, for every
170 /// revision before `2026-07-28`, and for a `2026-07-28` session whose
171 /// profile was resolved without it.
172 pub fn for_profile(
173 target: RoutingTarget,
174 profile: FeatureProfile,
175 ) -> Result<Self, RoutingHeaderError> {
176 if !profile.allows(Feature::StandardRoutingHeaders) {
177 return Err(RoutingHeaderError::FeatureUnavailable {
178 feature: Feature::StandardRoutingHeaders,
179 });
180 }
181 Ok(Self(target))
182 }
183
184 /// Parses the pair out of a request's header fields.
185 ///
186 /// `fields` is whatever the HTTP layer holds, in any case, in any order.
187 /// Names are matched case-insensitively; values are not, because both values
188 /// are case-sensitive in the IR.
189 ///
190 /// # Errors
191 ///
192 /// [`RoutingHeaderError::FeatureUnavailable`] when the profile does not
193 /// carry [`Feature::StandardRoutingHeaders`] — checked *before* the fields
194 /// are even read, so a pre-`2026-07-28` client is refused for the right
195 /// reason rather than for a missing header. Then
196 /// [`RoutingHeaderError::Missing`] naming whichever of the two is absent,
197 /// [`RoutingHeaderError::Repeated`] for a header sent twice, and the
198 /// value errors from [`MethodName::new`] and [`RoutingName::new`] for a
199 /// present but malformed pair.
200 pub fn parse<'a, I>(fields: I, profile: FeatureProfile) -> Result<Self, RoutingHeaderError>
201 where
202 I: IntoIterator<Item = (&'a str, &'a str)>,
203 {
204 if !profile.allows(Feature::StandardRoutingHeaders) {
205 return Err(RoutingHeaderError::FeatureUnavailable {
206 feature: Feature::StandardRoutingHeaders,
207 });
208 }
209 let mut method: Option<&str> = None;
210 let mut name: Option<&str> = None;
211 for (field, value) in fields {
212 if field.eq_ignore_ascii_case(MCP_METHOD_HEADER) {
213 set_once(&mut method, value)?;
214 } else if field.eq_ignore_ascii_case(MCP_NAME_HEADER) {
215 set_once(&mut name, value)?;
216 }
217 }
218 let method = method.ok_or(RoutingHeaderError::Missing {
219 header: MCP_METHOD_HEADER,
220 })?;
221 let name = name.ok_or(RoutingHeaderError::Missing {
222 header: MCP_NAME_HEADER,
223 })?;
224 Ok(Self(RoutingTarget::new(
225 MethodName::new(method)?,
226 RoutingName::new(name)?,
227 )))
228 }
229
230 /// What was addressed.
231 #[must_use]
232 pub const fn target(&self) -> &RoutingTarget {
233 &self.0
234 }
235
236 /// The header fields to put on the request, in the order they should go out.
237 ///
238 /// One serialiser, returned as an iterator of borrowed pairs, so the adapter
239 /// writes them into whatever header container it has without a second copy
240 /// of the spelling existing anywhere else.
241 pub fn fields(&self) -> impl Iterator<Item = (&'static str, &str)> {
242 [
243 (MCP_METHOD_HEADER, self.0.method.as_str()),
244 (MCP_NAME_HEADER, self.0.name.as_str()),
245 ]
246 .into_iter()
247 }
248}
249
250impl fmt::Display for RoutingHeaders {
251 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252 let mut first = true;
253 for (name, value) in self.fields() {
254 if !first {
255 formatter.write_str("; ")?;
256 }
257 write!(formatter, "{name}: {value}")?;
258 first = false;
259 }
260 Ok(())
261 }
262}
263
264/// Records a value, refusing a second one.
265///
266/// A repeated routing header is a refusal rather than a last-one-wins because
267/// the two values disagree about which operation is being called, and silently
268/// preferring one of them is a routing decision made by an attacker.
269const fn set_once<'a>(
270 slot: &mut Option<&'a str>,
271 value: &'a str,
272) -> Result<(), RoutingHeaderError> {
273 if slot.is_some() {
274 return Err(RoutingHeaderError::Repeated);
275 }
276 *slot = Some(value);
277 Ok(())
278}
279
280/// Why a request is not routable by header.
281#[derive(Debug, Clone, PartialEq, Eq, Error)]
282pub enum RoutingHeaderError {
283 /// The negotiated session was never offered the standard routing headers.
284 #[error("the negotiated profile does not include the {feature} feature")]
285 FeatureUnavailable {
286 /// The feature that was missing.
287 feature: Feature,
288 },
289
290 /// One of the two required headers was absent.
291 #[error("the required {header} header is missing")]
292 Missing {
293 /// The header that was absent.
294 header: &'static str,
295 },
296
297 /// A header arrived more than once.
298 #[error("a routing header was supplied more than once, and the two values disagree")]
299 Repeated,
300
301 /// The target name was blank.
302 #[error("Mcp-Name is empty")]
303 EmptyName,
304
305 /// A byte outside the visible-ASCII range reached a header value.
306 #[error(
307 "a routing header contains {byte:#04X} at byte {index}, which cannot be sent as an HTTP field-value"
308 )]
309 NonVisibleAscii {
310 /// Byte offset within the value.
311 index: usize,
312 /// The offending byte.
313 byte: u8,
314 },
315
316 /// The method name was not a well-formed JSON-RPC method.
317 #[error("Mcp-Method is not a well-formed method name: {0}")]
318 Method(#[from] crate::domain::ir::MethodNameError),
319}
320
321#[cfg(test)]
322mod tests {
323 use super::{
324 MCP_METHOD_HEADER, MCP_NAME_HEADER, RoutingHeaderError, RoutingHeaders, RoutingName,
325 RoutingTarget,
326 };
327 use crate::domain::Feature;
328 use crate::domain::feature::FeatureProfile;
329 use crate::domain::ir::MethodName;
330
331 /// A profile that carries the feature, standing in for a `2026-07-28`
332 /// session. Built through the matrix rather than hand-set so the test fails
333 /// if the matrix row moves.
334 fn negotiating_profile() -> FeatureProfile {
335 FeatureProfile::empty().with(Feature::StandardRoutingHeaders)
336 }
337
338 #[derive(Debug)]
339 enum Failure {
340 Routing(RoutingHeaderError),
341 Method(crate::domain::ir::MethodNameError),
342 /// A test's own assertion failed, with nothing for the parser to have
343 /// said about it.
344 Display,
345 }
346
347 impl From<RoutingHeaderError> for Failure {
348 fn from(error: RoutingHeaderError) -> Self {
349 Self::Routing(error)
350 }
351 }
352
353 impl From<crate::domain::ir::MethodNameError> for Failure {
354 fn from(error: crate::domain::ir::MethodNameError) -> Self {
355 Self::Method(error)
356 }
357 }
358
359 fn target() -> Result<RoutingTarget, Failure> {
360 Ok(RoutingTarget::new(
361 MethodName::new("tools/call")?,
362 RoutingName::new("weather")?,
363 ))
364 }
365
366 /// A well-formed pair on a session that has the feature parses, and lands on
367 /// the operation and the target the caller expected.
368 #[test]
369 fn parses_a_well_formed_pair() -> Result<(), Failure> {
370 let fields = [
371 (MCP_METHOD_HEADER, "tools/call"),
372 (MCP_NAME_HEADER, "weather"),
373 ];
374 let parsed = RoutingHeaders::parse(fields, negotiating_profile())?;
375 if parsed.target().method().as_str() != "tools/call" {
376 return Err(Failure::Routing(RoutingHeaderError::Missing {
377 header: MCP_METHOD_HEADER,
378 }));
379 }
380 if parsed.target().name().as_str() != "weather" {
381 return Err(Failure::Routing(RoutingHeaderError::EmptyName));
382 }
383 Ok(())
384 }
385
386 /// The spelling is the single place the header names are written. If the
387 /// specification's spelling ever changes, this is the test that says so
388 /// before an adapter does.
389 #[test]
390 fn emits_the_spelling_the_specification_fixes() -> Result<(), Failure> {
391 let headers = RoutingHeaders::for_profile(target()?, negotiating_profile())?;
392 let emitted: Vec<(&str, &str)> = headers.fields().collect();
393 let expected = vec![("Mcp-Method", "tools/call"), ("Mcp-Name", "weather")];
394 if emitted != expected {
395 return Err(Failure::Routing(RoutingHeaderError::Missing {
396 header: MCP_METHOD_HEADER,
397 }));
398 }
399 Ok(())
400 }
401
402 /// The pair emitted is exactly the pair that parses back, so a client using
403 /// our own writer is understood by our own reader.
404 #[test]
405 fn emitted_fields_reparse() -> Result<(), Failure> {
406 let headers = RoutingHeaders::for_profile(target()?, negotiating_profile())?;
407 let wire: Vec<(String, String)> = headers
408 .fields()
409 .map(|(name, value)| (name.to_owned(), value.to_owned()))
410 .collect();
411 let borrowed: Vec<(&str, &str)> = wire
412 .iter()
413 .map(|(name, value)| (name.as_str(), value.as_str()))
414 .collect();
415 if RoutingHeaders::parse(borrowed, negotiating_profile())? != headers {
416 return Err(Failure::Routing(RoutingHeaderError::Repeated));
417 }
418 Ok(())
419 }
420
421 /// `?` carries the concrete refusal rather than flattening it to a string.
422 ///
423 /// The tests above all convert through `?`, so if the conversion dropped
424 /// the error, every one of them would report the same uninformative
425 /// failure. This reads the payload back so the two variants stay distinct
426 /// and the error survives the trip.
427 #[test]
428 fn a_conversion_keeps_the_refusal_it_was_given() -> Result<(), Failure> {
429 let routing: Failure = RoutingHeaderError::Repeated.into();
430 let method: Failure = crate::domain::ir::MethodNameError::Empty.into();
431 let (Failure::Routing(repeated), Failure::Method(empty)) = (routing, method) else {
432 return Err(Failure::Display);
433 };
434 if repeated.to_string().is_empty() || empty.to_string().is_empty() {
435 return Err(Failure::Display);
436 }
437 Ok(())
438 }
439
440 /// Field names are case-insensitive per RFC 9110 §5.1, and a client that
441 /// lowercased them means the same request.
442 #[test]
443 fn accepts_header_names_in_any_case() -> Result<(), Failure> {
444 let fields = [("mcp-method", "tools/call"), ("MCP-NAME", "weather")];
445 let parsed = RoutingHeaders::parse(fields, negotiating_profile())?;
446 if parsed.target().name().as_str() != "weather" {
447 return Err(Failure::Routing(RoutingHeaderError::EmptyName));
448 }
449 Ok(())
450 }
451
452 /// Values stay case-sensitive. A method name lowercased by an intermediary
453 /// is a different method, and treating it as one would dispatch to the wrong
454 /// place rather than failing.
455 #[test]
456 fn does_not_fold_value_case() {
457 let fields = [
458 (MCP_METHOD_HEADER, "Tools/Call"),
459 (MCP_NAME_HEADER, "weather"),
460 ];
461 assert_eq!(
462 RoutingHeaders::parse(fields, FeatureProfile::all()).map(|h| h
463 .target()
464 .method()
465 .as_str()
466 .to_owned()),
467 Ok("Tools/Call".to_owned())
468 );
469 }
470
471 /// The gate is the reason this type exists, so it is checked on both
472 /// directions: building, and parsing.
473 #[test]
474 fn refuses_when_the_profile_lacks_the_feature() -> Result<(), Failure> {
475 let without = FeatureProfile::empty();
476 let expected = RoutingHeaderError::FeatureUnavailable {
477 feature: Feature::StandardRoutingHeaders,
478 };
479 if RoutingHeaders::for_profile(target()?, without) != Err(expected.clone()) {
480 return Err(Failure::Routing(expected));
481 }
482 // Even a perfectly formed pair is refused on a session that was never
483 // offered the feature, and the refusal is about the feature rather than
484 // about the headers.
485 let fields = [
486 (MCP_METHOD_HEADER, "tools/call"),
487 (MCP_NAME_HEADER, "weather"),
488 ];
489 if RoutingHeaders::parse(fields, without) != Err(expected.clone()) {
490 return Err(Failure::Routing(expected));
491 }
492 Ok(())
493 }
494
495 /// Each missing header is named, so a client that sent one of the two can
496 /// tell which to add.
497 #[test]
498 fn names_the_missing_header() {
499 let profile = FeatureProfile::all();
500 assert_eq!(
501 RoutingHeaders::parse([(MCP_METHOD_HEADER, "tools/call")], profile),
502 Err(RoutingHeaderError::Missing {
503 header: MCP_NAME_HEADER
504 })
505 );
506 assert_eq!(
507 RoutingHeaders::parse([(MCP_NAME_HEADER, "weather")], profile),
508 Err(RoutingHeaderError::Missing {
509 header: MCP_METHOD_HEADER
510 })
511 );
512 }
513
514 /// A repeated header is a refusal, not last-one-wins.
515 #[test]
516 fn refuses_a_repeated_header() {
517 let fields = [
518 (MCP_METHOD_HEADER, "tools/call"),
519 (MCP_METHOD_HEADER, "resources/read"),
520 (MCP_NAME_HEADER, "weather"),
521 ];
522 assert_eq!(
523 RoutingHeaders::parse(fields, FeatureProfile::all()),
524 Err(RoutingHeaderError::Repeated)
525 );
526 }
527
528 /// A value that cannot be a header field-value is refused at the point it is
529 /// parsed, naming the byte — before it can reach an adapter that would
530 /// discover it by failing to write.
531 #[test]
532 fn refuses_a_non_visible_ascii_value() {
533 let fields = [
534 (MCP_METHOD_HEADER, "tools/call"),
535 (MCP_NAME_HEADER, "wea\nther"),
536 ];
537 assert_eq!(
538 RoutingHeaders::parse(fields, FeatureProfile::all()),
539 Err(RoutingHeaderError::NonVisibleAscii {
540 index: 3,
541 byte: b'\n'
542 })
543 );
544 }
545
546 /// A malformed method name is refused through the IR's own error, so the
547 /// header path and the body path cannot disagree about what a method name
548 /// is.
549 #[test]
550 fn defers_method_validation_to_the_ir() {
551 let fields = [(MCP_METHOD_HEADER, "tools/"), (MCP_NAME_HEADER, "weather")];
552 let refused = RoutingHeaders::parse(fields, FeatureProfile::all());
553 assert!(
554 matches!(refused, Err(RoutingHeaderError::Method(_))),
555 "{refused:?}"
556 );
557 }
558
559 /// A blank target name is refused. It is the one value check that is not
560 /// delegated, because `MethodName` has no counterpart to defer to.
561 #[test]
562 fn refuses_a_blank_target_name() {
563 assert_eq!(RoutingName::new(""), Err(RoutingHeaderError::EmptyName));
564 }
565}