protocol_droid/domain/mcp/session_id.rs
1//! `McpSessionId` — the `Mcp-Session-Id` header value, as a value object.
2//!
3//! # Where the two requirements come from, and they are not the same one
4//!
5//! The visible-ASCII rule is **HTTP's**, not MCP's. `Mcp-Session-Id` is a header
6//! field-value, and RFC 9110 §5.5 defines a field-value over `field-vchar`,
7//! which is `%x21-7E` plus SP — `%x20-7E` in the form the task brief states. Any
8//! implementation that puts a non-visible-ASCII byte in a header is not sending
9//! a header at all. That is the reason this type validates, and it is a
10//! transport requirement that would hold for any header this crate ever sends.
11//!
12//! The 16-digit hexadecimal spelling is **ours**. The specification that
13//! introduced this header was removed in `2026-07-28` (major change 1, SEP-2567,
14//! `crates/protocol-droid-schema/spec/2026-07-28/CHANGELOG.md`) without ever
15//! fixing a format for it, and no earlier vendored revision states one. So the
16//! encoding here is a decision, documented below, rather than a fact read out of
17//! the schema — and a peer is required to treat it as opaque either way.
18//!
19//! # Why the format is hexadecimal, and why that is not a security problem
20//!
21//! `SessionId` is a `u64`, so *any* encoding of it is at most 64 bits and the
22//! encoding cannot add entropy. Rendering it as 16 lowercase-free hex digits
23//! costs one stack buffer and no allocation on a path that runs on every
24//! response header, via [`core::fmt::NumBuffer`]. A denser base-32 spelling would
25//! save three characters and buy nothing an attacker could not also get from the
26//! `u64` underneath.
27//!
28//! Enumerable session ids are acceptable here because the specification does not
29//! make the id a capability, and this crate says so at the source:
30//! [`IdSource`](crate::application::session::IdSource) documents that an id "is
31//! not a capability, not a secret, and not something a peer is ever asked to
32//! trust". A deployment that needs unguessable ids uses
33//! `adapters::secondary::id_source::RandomIds`, behind the `random-ids` feature;
34//! the encoding cannot supply entropy it does not have. That module is
35//! optional, so this is a path rather than an intra-doc link — a link to it
36//! would break the documentation of every lean build, and this file is
37//! compiled in all of them.
38//!
39//! # Why this is a separate newtype rather than a rendering of `SessionId`
40//!
41//! Because the two make different promises. `SessionId` is the internal identity
42//! the event log correlates and the redaction rule truncates, and it makes no
43//! claim about bytes a peer can send back. This type makes a claim about a
44//! header grammar, and it has to be able to *reject* — a peer's `Mcp-Session-Id`
45//! arrives as arbitrary text and must be parsed. A type that cannot be
46//! constructed from text cannot reject anything, and a `SessionId` that
47//! validated `%x20-7E` would be validating a constraint no internal caller has
48//! any reason to respect.
49//!
50//! The mapping between them is a bijection, which is what makes the design
51//! worth the extra type: every `McpSessionId` names exactly one `SessionId`, so
52//! an inbound header always resolves to a session the server actually has, and
53//! no wire id can exist that no session owns. A second minted id instead of an
54//! encoding would break exactly that property — there would be ids in the
55//! header space with no session behind them, and a lookup that had to handle it.
56
57use core::fmt;
58use core::num::ParseIntError;
59use core::str::FromStr;
60
61use thiserror::Error;
62
63use crate::domain::session::SessionId;
64
65/// The byte range an `Mcp-Session-Id` value is drawn from, inclusive.
66///
67/// RFC 9110 §5.5 `field-vchar` plus SP. Exposed so the test that proves the
68/// encoding is in range and the parser that enforces it cite the same two
69/// numbers instead of each spelling their own.
70pub const VISIBLE_ASCII_RANGE: core::ops::RangeInclusive<u8> = 0x21..=0x7E;
71
72/// How many characters [`McpSessionId`] renders.
73///
74/// A `u64` needs sixteen hex digits, and sixteen is fixed rather than minimal so
75/// that two ids can never differ in length — a length-sensitive intermediary
76/// between us and the client would otherwise see `7` and `0007` as different
77/// sessions.
78const DIGITS: usize = 16;
79
80/// The `Mcp-Session-Id` header value naming one session.
81///
82/// `Copy`, because the only field is a [`SessionId`], which is `Copy`. It is
83/// therefore a plain `u64` on the wire path, which is the point: this sits in
84/// the per-request state and is compared against on every request.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub struct McpSessionId(SessionId);
87
88impl McpSessionId {
89 /// The wire id for an internal session.
90 ///
91 /// Infallible, and it is infallible by construction rather than by trusting
92 /// the caller: the rendering is a fixed 16-digit hex expansion of a `u64`,
93 /// every one of which is inside [`VISIBLE_ASCII_RANGE`].
94 #[must_use]
95 pub const fn new(session: SessionId) -> Self {
96 Self(session)
97 }
98
99 /// The internal session this header names.
100 ///
101 /// The inverse of [`new`](Self::new), and total: every id this type can
102 /// hold has a session, because the id *is* a session.
103 #[must_use]
104 pub const fn session(self) -> SessionId {
105 self.0
106 }
107
108 /// The value as bytes, for an adapter filling in a header.
109 ///
110 /// Takes a buffer rather than returning a `String` because the alternative
111 /// allocates once per response and this is on every one of them. The buffer
112 /// only has to outlive the borrow it returns.
113 #[must_use]
114 pub fn as_bytes(self, buffer: &mut [u8; DIGITS]) -> &[u8; DIGITS] {
115 // Written out nibble by nibble rather than through `format_into`,
116 // which renders decimal: the header is sixteen hex digits, zero-padded,
117 // and a decimal rendering of the same number is a different value to
118 // whatever parses it back. The width is fixed by the loop, so it cannot
119 // drift from `DIGITS`, and every digit it writes is a hex letter or
120 // an arabic numeral — inside [`VISIBLE_ASCII_RANGE`] by construction.
121 const HEX: &[u8; 16] = b"0123456789abcdef";
122 let mut remaining = self.0.get();
123 for slot in buffer.iter_mut().rev() {
124 // `remaining & 0xF` is 0..=15 by construction, so the conversion
125 // cannot fail; the fallback is here so a widening integer type
126 // would be a slow default rather than a panic on the request path.
127 let index = usize::from(u8::try_from(remaining & 0xF).unwrap_or(0));
128 *slot = HEX.get(index).copied().unwrap_or(b'0');
129 remaining >>= 4;
130 }
131 buffer
132 }
133}
134
135impl fmt::Display for McpSessionId {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 let mut buffer = [0u8; DIGITS];
138 formatter.write_str(core::str::from_utf8(self.as_bytes(&mut buffer)).unwrap_or_default())
139 }
140}
141
142impl From<SessionId> for McpSessionId {
143 fn from(session: SessionId) -> Self {
144 Self(session)
145 }
146}
147
148impl From<McpSessionId> for SessionId {
149 fn from(id: McpSessionId) -> Self {
150 id.0
151 }
152}
153
154impl FromStr for McpSessionId {
155 type Err = McpSessionIdError;
156
157 fn from_str(text: &str) -> Result<Self, Self::Err> {
158 let value = McpSessionIdError::validate(text)?;
159 let digits = u64::from_str_radix(value, 16)
160 .map_err(|source| McpSessionIdError::Unparsable { source })?;
161 Ok(Self(SessionId::new(digits)))
162 }
163}
164
165/// Why a `Mcp-Session-Id` header is not an [`McpSessionId`].
166///
167/// Four distinct refusals rather than one, because they are four different
168/// mistakes and a caller that gets one of them back needs to know which. A
169/// single "invalid" string would make the common case — a client that sent the
170/// wrong header name, or one that pre-dates the session concept — impossible to
171/// tell apart from a truncated value.
172///
173/// Deliberately not `Copy`, unlike the value types beside it: the last variant
174/// carries a [`ParseIntError`], and `Copy` is only worth having on a type that
175/// lives on the request path. This one is produced once, on a refusal.
176#[derive(Debug, Clone, PartialEq, Eq, Error)]
177pub enum McpSessionIdError {
178 /// The value was nothing but optional whitespace.
179 #[error("Mcp-Session-Id is empty once surrounding whitespace is trimmed")]
180 Empty,
181
182 /// A byte outside the visible-ASCII range reached the value.
183 #[error(
184 "Mcp-Session-Id contains {byte:#04X} at byte {index}, outside the \
185 visible ASCII range {low:#04X}-{high:#04X} required of an HTTP field-value"
186 )]
187 NotVisibleAscii {
188 /// Byte offset within the trimmed value.
189 index: usize,
190 /// The offending byte.
191 byte: u8,
192 /// Lowest permitted byte, [`VISIBLE_ASCII_RANGE`]'s start.
193 low: u8,
194 /// Highest permitted byte, [`VISIBLE_ASCII_RANGE`]'s end.
195 high: u8,
196 },
197
198 /// The value was not sixteen characters.
199 #[error("Mcp-Session-Id must be {expected} hexadecimal digits, found {found}")]
200 WrongLength {
201 /// The fixed width, `DIGITS`.
202 expected: usize,
203 /// What arrived.
204 found: usize,
205 },
206
207 /// A character in the value was not a hexadecimal digit.
208 #[error(
209 "Mcp-Session-Id contains {character:?} at byte {index}, which is not a hexadecimal digit"
210 )]
211 NotHex {
212 /// Byte offset within the trimmed value.
213 index: usize,
214 /// The offending character.
215 character: char,
216 },
217
218 /// The digits were well-formed but did not denote a `u64`.
219 #[error("Mcp-Session-Id is not a valid hexadecimal number: {source}")]
220 Unparsable {
221 /// The underlying conversion failure.
222 source: ParseIntError,
223 },
224}
225
226impl McpSessionIdError {
227 /// Checks a raw header value and returns its significant part.
228 ///
229 /// Trimming first is not leniency for its own sake: RFC 9110 §5.5 allows
230 /// optional whitespace around a field-value and an HTTP parser is entitled
231 /// to hand the field with it still attached. Rejecting a legal header
232 /// because of transport framing the client could not have avoided would be
233 /// our bug, not theirs.
234 ///
235 /// Interior whitespace is *not* tolerated even though `field-value`
236 /// technically permits it, and the narrowing is deliberate: that allowance
237 /// is for a human-typed `Subject:`, and a session id is a machine-carried
238 /// identifier that no reader would expect to be trimmed. A value with a tab
239 /// in the middle is refused as not visible ASCII, which is where a reader
240 /// would look for it.
241 ///
242 /// # Errors
243 ///
244 /// [`McpSessionIdError::Empty`] for a wholly blank value, and
245 /// [`McpSessionIdError::NotVisibleAscii`] naming the first byte that is out
246 /// of range. Only the *first* offender is reported: the value is a header,
247 /// and a message listing every bad byte in a 4 KiB garbage string is noise.
248 ///
249 /// This checks only what the specification requires of a session id:
250 /// "The session ID **MUST** only contain visible ASCII characters", and it
251 /// is otherwise the server's to shape. A *client* holds whatever id the
252 /// server it dialled issued — a UUID, a JWT, a counter — so it must not
253 /// insist on the sixteen-digit form this crate's own server happens to
254 /// write; [`validate`](Self::validate) is for the server side, which does.
255 pub fn validate_opaque(value: &str) -> Result<&str, Self> {
256 let trimmed = value.trim_matches([' ', '\t']);
257 if trimmed.is_empty() {
258 return Err(Self::Empty);
259 }
260 // The range check runs first, ahead of the width check. A value that
261 // carries a character outside visible ASCII is not a session id of the
262 // wrong length, it is not a session id at all, and reporting its width
263 // would describe a problem the caller does not have.
264 //
265 // Walked by `char_indices` rather than by `bytes` so the index reported
266 // is where the character *starts* and the value reported is the
267 // character itself. A byte walk would name the UTF-8 lead byte of
268 // `é` (0xC3) rather than `é` (0xE9), and the caller would be left
269 // decoding the difference.
270 // The specification's range is `0x21..=0x7E` — a space is not a
271 // visible character, and an id with one in the middle could not survive
272 // a proxy that normalises whitespace.
273 for (index, character) in trimmed.char_indices() {
274 let byte = u8::try_from(character).unwrap_or(u8::MAX);
275 if !VISIBLE_ASCII_RANGE.contains(&byte) {
276 return Err(Self::NotVisibleAscii {
277 index,
278 byte,
279 low: *VISIBLE_ASCII_RANGE.start(),
280 high: *VISIBLE_ASCII_RANGE.end(),
281 });
282 }
283 }
284 Ok(trimmed)
285 }
286
287 /// Checks that a value is the form this crate's own server issues.
288 ///
289 /// # Errors
290 ///
291 /// Everything [`validate_opaque`](Self::validate_opaque) refuses, plus a
292 /// value that is not sixteen hexadecimal digits.
293 pub fn validate(value: &str) -> Result<&str, Self> {
294 let trimmed = Self::validate_opaque(value)?;
295 // Width is measured in **bytes**, because that is what an HTTP field
296 // carries and what the header is written as. Counting characters
297 // instead would let a multi-byte character pad a short value up to the
298 // right width, which is a value nothing can round-trip.
299 if trimmed.len() != DIGITS {
300 return Err(Self::WrongLength {
301 expected: DIGITS,
302 found: trimmed.len(),
303 });
304 }
305 for (index, character) in trimmed.char_indices() {
306 if !character.is_ascii_hexdigit() {
307 return Err(Self::NotHex { index, character });
308 }
309 }
310 Ok(trimmed)
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::{DIGITS, McpSessionId, McpSessionIdError, VISIBLE_ASCII_RANGE};
317 use crate::domain::session::SessionId;
318 use core::str::FromStr;
319
320 /// Every failure below returns `Result` and every call is `?`'d, so a
321 /// broken fixture reports itself rather than needing an `.unwrap()` to
322 /// surface as a panic.
323 #[derive(Debug)]
324 enum Failure {
325 Session(McpSessionIdError),
326 Display,
327 }
328
329 impl From<McpSessionIdError> for Failure {
330 fn from(error: McpSessionIdError) -> Self {
331 Self::Session(error)
332 }
333 }
334
335 /// `?` carries the concrete refusal rather than flattening it, so a test
336 /// that expected a parse to fail still says _why_ it did.
337 #[test]
338 fn a_conversion_keeps_the_refusal_it_was_given() -> Result<(), Failure> {
339 let failure: Failure = McpSessionIdError::Empty.into();
340 let Failure::Session(reason) = failure else {
341 return Err(Failure::Display);
342 };
343 if reason.to_string().is_empty() {
344 return Err(Failure::Display);
345 }
346 Ok(())
347 }
348
349 /// The wire form of a freshly minted id is exactly the fixed width and is
350 /// visible ASCII byte for byte, for the whole `u64` range.
351 ///
352 /// The width claim is the load-bearing one: a shorter-than-16 rendering
353 /// would still round-trip, so only this test catches it.
354 #[test]
355 fn rendering_is_fixed_width_visible_ascii() -> Result<(), Failure> {
356 // A handful of values chosen to cover the boundaries of a u64 plus the
357 // digits that differ most in their high bit.
358 for value in [0, 1, 9, 10, 15, 16, 255, 256, u32::MAX.into(), u64::MAX] {
359 let id = McpSessionId::new(SessionId::new(value));
360 let rendered = id.to_string();
361 if rendered.len() != DIGITS {
362 return Err(Failure::Display);
363 }
364 if !rendered.bytes().all(|b| VISIBLE_ASCII_RANGE.contains(&b)) {
365 return Err(Failure::Display);
366 }
367 }
368 Ok(())
369 }
370
371 /// The known encodings, spelled out, so a change to the format is a
372 /// deliberate edit here rather than a silent one.
373 #[test]
374 fn renders_the_documented_spelling() -> Result<(), Failure> {
375 for (session, expected) in [
376 (0, "0000000000000000"),
377 (1, "0000000000000001"),
378 (255, "00000000000000ff"),
379 ] {
380 if McpSessionId::new(SessionId::new(session)).to_string() != expected {
381 return Err(Failure::Display);
382 }
383 }
384 Ok(())
385 }
386
387 /// Render, parse, and land back on the same session — the bijection the
388 /// "derive, do not mint a second id" decision rests on.
389 #[test]
390 fn round_trips_through_the_header_form() -> Result<(), Failure> {
391 for value in [0, 1, 7, 123_456_789, u64::MAX] {
392 let original = McpSessionId::new(SessionId::new(value));
393 let parsed = McpSessionId::from_str(&original.to_string())?;
394 if parsed != original {
395 return Err(Failure::Display);
396 }
397 if parsed.session() != SessionId::new(value) {
398 return Err(Failure::Display);
399 }
400 }
401 Ok(())
402 }
403
404 /// Lowercase is accepted and normalises to the canonical uppercase spelling,
405 /// because the id is a value and a client is not required to preserve its
406 /// case to be understood.
407 #[test]
408 fn accepts_lowercase_and_agrees_with_uppercase() -> Result<(), Failure> {
409 if McpSessionId::from_str("00000000000000ff")?
410 != McpSessionId::from_str("00000000000000FF")?
411 {
412 return Err(Failure::Display);
413 }
414 Ok(())
415 }
416
417 /// Surrounding whitespace is transport framing and is trimmed rather than
418 /// refused, per RFC 9110 §5.5's optional whitespace.
419 #[test]
420 fn trims_surrounding_whitespace() -> Result<(), Failure> {
421 if McpSessionId::from_str(" 0000000000000001\t")?.session() != SessionId::new(1) {
422 return Err(Failure::Display);
423 }
424 Ok(())
425 }
426
427 /// A byte outside `%x20-7E` is refused, and the refusal names the byte and
428 /// where it was. This is the header-grammar rule, distinct from the format
429 /// rule: the value below is also the right length and fails earlier.
430 #[test]
431 fn refuses_non_visible_ascii_naming_the_byte() {
432 let refused = McpSessionId::from_str("000000000000000\n");
433 assert_eq!(
434 refused,
435 Err(McpSessionIdError::NotVisibleAscii {
436 index: 15,
437 byte: b'\n',
438 low: *VISIBLE_ASCII_RANGE.start(),
439 high: *VISIBLE_ASCII_RANGE.end(),
440 })
441 );
442 }
443
444 /// A byte in the obs-text range — legal in a `field-value` per RFC 9110 and
445 /// still refused, because the encoding has no non-ASCII spelling.
446 #[test]
447 fn refuses_obs_text() {
448 assert_eq!(
449 McpSessionId::from_str("00000000000000\u{00ff}"),
450 Err(McpSessionIdError::NotVisibleAscii {
451 index: 14,
452 byte: 0xff,
453 low: *VISIBLE_ASCII_RANGE.start(),
454 high: *VISIBLE_ASCII_RANGE.end(),
455 })
456 );
457 }
458
459 /// Blank, wrong-width, and non-hex are three different mistakes and come
460 /// back as three different variants, so a caller can tell a truncated value
461 /// from a mangled one.
462 #[test]
463 fn distinguishes_empty_wrong_length_and_non_hex() {
464 assert_eq!(McpSessionId::from_str(" "), Err(McpSessionIdError::Empty));
465 assert_eq!(McpSessionId::from_str(""), Err(McpSessionIdError::Empty));
466 assert_eq!(
467 McpSessionId::from_str("0001"),
468 Err(McpSessionIdError::WrongLength {
469 expected: DIGITS,
470 found: 4,
471 })
472 );
473 assert_eq!(
474 McpSessionId::from_str("zzzzzzzzzzzzzzzz"),
475 Err(McpSessionIdError::NotHex {
476 index: 0,
477 character: 'z',
478 })
479 );
480 }
481
482 /// A character above ASCII is caught by the range check before it can be
483 /// named as a `char`, so `NotHex` never has to represent a non-ASCII
484 /// character.
485 #[test]
486 fn range_check_runs_before_the_digit_check() {
487 assert_eq!(
488 McpSessionId::from_str(
489 "\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}\u{00e9}"
490 ),
491 Err(McpSessionIdError::NotVisibleAscii {
492 index: 0,
493 byte: 0xe9,
494 low: *VISIBLE_ASCII_RANGE.start(),
495 high: *VISIBLE_ASCII_RANGE.end(),
496 })
497 );
498 }
499
500 /// An id is an ordinary `u64` underneath, so the conversions between the
501 /// two identities are the identity. Checked directly so a future
502 /// non-`Copy` field cannot quietly change the answer.
503 #[test]
504 fn converts_to_and_from_the_internal_id() -> Result<(), Failure> {
505 let internal = SessionId::new(42);
506 let wire: McpSessionId = internal.into();
507 if wire.session() != internal {
508 return Err(Failure::Display);
509 }
510 let back: SessionId = wire.into();
511 if back != internal {
512 return Err(Failure::Display);
513 }
514 Ok(())
515 }
516
517 /// `as_bytes` and `Display` are two renderings of one format and must not
518 /// be allowed to drift.
519 #[test]
520 fn bytes_and_display_agree() -> Result<(), Failure> {
521 let id = McpSessionId::new(SessionId::new(0xdead_beef));
522 let mut buffer = [0u8; DIGITS];
523 if id.as_bytes(&mut buffer).as_slice() != id.to_string().into_bytes() {
524 return Err(Failure::Display);
525 }
526 Ok(())
527 }
528}