Skip to main content

mcp_usage_core/
name.rs

1//! Decoding the `Mcp-Name` header value.
2//!
3//! `Mcp-Name` mirrors `params.name` (a tool or prompt name) or `params.uri` (a
4//! resource URI). Tool names are only SHOULD-constrained to header-safe
5//! characters and URIs are not constrained at all, so the spec defines a sentinel
6//! escape: a value that cannot ride in an ASCII header field arrives as
7//!
8//! ```text
9//! Mcp-Name: =?base64?{Base64EncodedValue}?=
10//! ```
11//!
12//! The markers are lowercase and case-sensitive, and the payload is Base64 over
13//! the UTF-8 bytes using the **standard** alphabet (the spec's own example
14//! `=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=` contains `/`, so it is not URL-safe
15//! Base64).
16//!
17//! This matters to a meter for one blunt reason: the price book is keyed on tool
18//! name. Matching `=?base64?...?=` against it does not merely fail to find the
19//! tool, it silently prices every non-ASCII-named tool at the default rate. The
20//! spec requires servers and intermediaries that inspect these values to "decode
21//! them accordingly" before comparing.
22//!
23//! Clients MUST also encode any plain-ASCII value that happens to look like the
24//! sentinel, so decoding is unambiguous: anything wearing the markers is encoded.
25
26use base64::Engine as _;
27use base64::engine::general_purpose::STANDARD;
28use std::borrow::Cow;
29use std::fmt;
30
31/// Opening marker of the Base64 sentinel form.
32pub const SENTINEL_PREFIX: &str = "=?base64?";
33/// Closing marker of the Base64 sentinel form.
34pub const SENTINEL_SUFFIX: &str = "?=";
35
36/// Why a `Mcp-Name` value could not be decoded.
37///
38/// Both variants mean the header wore the sentinel markers but its payload was
39/// not a valid Base64-encoded UTF-8 string. A conforming client cannot produce
40/// either, so the right response is to reject the request rather than guess.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum NameError {
43    /// The payload between the markers was not valid standard-alphabet Base64.
44    NotBase64,
45    /// The payload decoded, but the bytes were not valid UTF-8.
46    NotUtf8,
47}
48
49impl fmt::Display for NameError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::NotBase64 => f.write_str("Mcp-Name sentinel payload is not valid base64"),
53            Self::NotUtf8 => f.write_str("Mcp-Name sentinel payload is not valid UTF-8"),
54        }
55    }
56}
57
58impl std::error::Error for NameError {}
59
60/// Decode a raw `Mcp-Name` header value to the name the body actually carries.
61///
62/// A plain value is returned borrowed, so the common path allocates nothing.
63/// Only the sentinel form allocates, and only because it must.
64///
65/// A value that opens with the prefix but does not close with the suffix is
66/// treated as a literal rather than an error: it is not wearing the complete
67/// sentinel, so by the spec's own encoding rule it cannot be an encoded value.
68///
69/// # Errors
70///
71/// Returns [`NameError`] when the value wears both markers but the payload
72/// between them is not Base64-encoded UTF-8.
73pub fn decode(raw: &str) -> Result<Cow<'_, str>, NameError> {
74    let Some(rest) = raw.strip_prefix(SENTINEL_PREFIX) else {
75        return Ok(Cow::Borrowed(raw));
76    };
77    let Some(payload) = rest.strip_suffix(SENTINEL_SUFFIX) else {
78        return Ok(Cow::Borrowed(raw));
79    };
80
81    let bytes = STANDARD.decode(payload).map_err(|_| NameError::NotBase64)?;
82    let decoded = String::from_utf8(bytes).map_err(|_| NameError::NotUtf8)?;
83    Ok(Cow::Owned(decoded))
84}
85
86/// Whether a raw header value is wearing the complete Base64 sentinel.
87#[must_use]
88pub fn is_sentinel(raw: &str) -> bool {
89    raw.strip_prefix(SENTINEL_PREFIX)
90        .is_some_and(|rest| rest.ends_with(SENTINEL_SUFFIX))
91}
92
93/// Encode a value into the sentinel form. Test and client-side helper.
94#[must_use]
95pub fn encode(value: &str) -> String {
96    format!(
97        "{SENTINEL_PREFIX}{}{SENTINEL_SUFFIX}",
98        STANDARD.encode(value.as_bytes())
99    )
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn plain_ascii_passes_through_borrowed() {
108        let decoded = decode("get_weather").expect("plain value decodes");
109        assert_eq!(decoded, "get_weather");
110        assert!(
111            matches!(decoded, Cow::Borrowed(_)),
112            "plain path must not allocate"
113        );
114    }
115
116    #[test]
117    fn decodes_the_specs_own_examples() {
118        // Straight from the Value Encoding table in the Streamable HTTP page.
119        assert_eq!(
120            decode("=?base64?SGVsbG8sIOS4lueVjA==?=").unwrap(),
121            "Hello, 世界"
122        );
123        assert_eq!(decode("=?base64?IHBhZGRlZCA=?=").unwrap(), " padded ");
124        assert_eq!(
125            decode("=?base64?bGluZTEKbGluZTI=?=").unwrap(),
126            "line1\nline2"
127        );
128        // A literal that merely looks like the sentinel, which clients must encode
129        // which keeps this case unambiguous.
130        assert_eq!(
131            decode("=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=").unwrap(),
132            "=?base64?literal?="
133        );
134    }
135
136    #[test]
137    fn uses_the_standard_alphabet_not_url_safe() {
138        // The literal example above encodes to a payload containing '/'. Under the
139        // URL-safe alphabet that byte would be '_' and the decode would disagree.
140        let encoded = encode("=?base64?literal?=");
141        assert!(
142            encoded.contains('/'),
143            "expected standard-alphabet output: {encoded}"
144        );
145        assert_eq!(decode(&encoded).unwrap(), "=?base64?literal?=");
146    }
147
148    #[test]
149    fn resource_uris_survive_the_round_trip() {
150        let uri = "file:///projects/myapp/config.json";
151        assert_eq!(decode(uri).unwrap(), uri);
152        assert_eq!(decode(&encode(uri)).unwrap(), uri);
153    }
154
155    #[test]
156    fn empty_sentinel_payload_decodes_to_empty() {
157        assert_eq!(decode("=?base64??=").unwrap(), "");
158    }
159
160    #[test]
161    fn partial_markers_are_literals_not_errors() {
162        // Opens the sentinel but never closes it: not an encoded value.
163        assert_eq!(decode("=?base64?SGVsbG8=").unwrap(), "=?base64?SGVsbG8=");
164        // Closes but never opens.
165        assert_eq!(decode("SGVsbG8=?=").unwrap(), "SGVsbG8=?=");
166        // Prefix immediately followed by the suffix marker's tail only.
167        assert_eq!(decode("=?base64?=").unwrap(), "=?base64?=");
168    }
169
170    #[test]
171    fn markers_are_case_sensitive() {
172        // "These markers are case-sensitive and MUST appear exactly as shown
173        // (lowercase)." An uppercase spelling is a literal name.
174        assert_eq!(
175            decode("=?BASE64?SGVsbG8=?=").unwrap(),
176            "=?BASE64?SGVsbG8=?="
177        );
178        assert!(!is_sentinel("=?BASE64?SGVsbG8=?="));
179    }
180
181    #[test]
182    fn malformed_payloads_are_rejected_rather_than_guessed() {
183        assert_eq!(
184            decode("=?base64?not!valid!base64?=").unwrap_err(),
185            NameError::NotBase64
186        );
187        // 0xFF is not valid UTF-8.
188        let bad_utf8 = format!(
189            "{SENTINEL_PREFIX}{}{SENTINEL_SUFFIX}",
190            STANDARD.encode([0xFF])
191        );
192        assert_eq!(decode(&bad_utf8).unwrap_err(), NameError::NotUtf8);
193    }
194
195    #[test]
196    fn is_sentinel_agrees_with_decode() {
197        for raw in [
198            "get_weather",
199            "=?base64?SGVsbG8=?=",
200            "=?base64?SGVsbG8=",
201            "=?base64??=",
202        ] {
203            let sentinel = is_sentinel(raw);
204            let changed = !matches!(decode(raw), Ok(Cow::Borrowed(_)));
205            assert_eq!(sentinel, changed, "disagreement on {raw}");
206        }
207    }
208
209    #[test]
210    fn round_trips_arbitrary_unicode() {
211        for value in [
212            "",
213            "a",
214            "日本語",
215            "emoji 🦀 tool",
216            "  spaced  ",
217            "tab\there",
218        ] {
219            assert_eq!(
220                decode(&encode(value)).unwrap(),
221                value,
222                "failed on {value:?}"
223            );
224        }
225    }
226}