Skip to main content

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}