1use base64::Engine as _;
27use base64::engine::general_purpose::STANDARD;
28use std::borrow::Cow;
29use std::fmt;
30
31pub const SENTINEL_PREFIX: &str = "=?base64?";
33pub const SENTINEL_SUFFIX: &str = "?=";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum NameError {
43 NotBase64,
45 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
60pub 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#[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#[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 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 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 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 assert_eq!(decode("=?base64?SGVsbG8=").unwrap(), "=?base64?SGVsbG8=");
164 assert_eq!(decode("SGVsbG8=?=").unwrap(), "SGVsbG8=?=");
166 assert_eq!(decode("=?base64?=").unwrap(), "=?base64?=");
168 }
169
170 #[test]
171 fn markers_are_case_sensitive() {
172 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 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}