Skip to main content

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}