Skip to main content

protocol_droid/domain/ir/
message.rs

1//! The message envelope — request, notification, result, error.
2//!
3//! # Why the envelope is not a codec type
4//!
5//! The IR has to cover every method in every revision, which is a lot of
6//! distinct result and parameter shapes. Modelling all of them here would be
7//! two crates' worth of enums whose only job was to be lowered again, and most
8//! of them are structurally identical — "a list of resources" is the same shape
9//! for `resources/list` in every revision.
10//!
11//! So the envelope is generic: [`IrResult`] and [`IrParams`] hold an
12//! [`Extensions`] bag for the fields the IR does not model, and T08's codecs
13//! populate those for the methods that need it. The shapes that *do* matter —
14//! content, tool results — have real types, because they are what user handlers
15//! touch and what lowering has to reason about.
16//!
17//! The trade is deliberate and worth stating: the IR is complete (nothing is
18//! lost) but not fully typed (some things are opaque). Completeness is the
19//! property the bridge needs; full typing is a convenience, and paying for it
20//! up front would mean the IR had to change every time a revision adds a field.
21//!
22//! # `IrMessage` covers all four JSON-RPC shapes
23//!
24//! The spec requires a bridge to forward notifications without a reply and
25//! results only against the request that caused them. Modelling all four here
26//! means the envelope is the only place that knows about JSON-RPC, and the
27//! session engine (T10) does not re-derive it.
28
29use std::sync::Arc;
30
31use crate::domain::RequestId;
32use crate::domain::ir::extensions::Extensions;
33use crate::domain::ir::features::FeatureSet;
34
35/// A method name, validated.
36///
37/// A newtype for the same reason `TransportSpec` is: a method name is a string
38/// that has to be exact, and passing a raw `&str` around means the "did you
39/// mean `notifications/cancelled` or `notifications/canceled`" check lives in
40/// whoever happens to be comparing. Constructed from a literal or `FromStr`, so
41/// the check is at the boundary.
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct MethodName(Arc<str>);
44
45impl MethodName {
46    /// A method name, validated as non-empty and slash-separated.
47    ///
48    /// # Errors
49    ///
50    /// [`MethodNameError::Empty`] when the name is empty,
51    /// [`MethodNameError::NoSegment`] when it starts or ends with `/`, and
52    /// [`MethodNameError::Whitespace`] when a segment has surrounding space —
53    /// the last of which catches `"tools/list "` losing a race in string
54    /// concatenation.
55    pub fn new(name: &str) -> Result<Self, MethodNameError> {
56        if name.is_empty() {
57            return Err(MethodNameError::Empty);
58        }
59        if name.starts_with('/') || name.ends_with('/') {
60            return Err(MethodNameError::NoSegment {
61                name: name.to_owned(),
62            });
63        }
64        if name.split('/').any(|segment| segment.trim() != segment) {
65            return Err(MethodNameError::Whitespace {
66                name: name.to_owned(),
67            });
68        }
69        Ok(Self(Arc::from(name)))
70    }
71
72    /// The name.
73    #[must_use]
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77
78    /// Whether the name is a notification, which is the one naming convention
79    /// the spec fixes across every revision.
80    #[must_use]
81    pub fn is_notification(&self) -> bool {
82        self.0.starts_with("notifications/")
83    }
84}
85
86impl core::fmt::Display for MethodName {
87    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
88        formatter.write_str(&self.0)
89    }
90}
91
92impl core::str::FromStr for MethodName {
93    type Err = MethodNameError;
94
95    fn from_str(text: &str) -> Result<Self, Self::Err> {
96        Self::new(text)
97    }
98}
99
100/// Why a method name is not a [`MethodName`].
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102pub enum MethodNameError {
103    /// The name was empty.
104    #[error("method name is empty")]
105    Empty,
106    /// The name started or ended with a slash.
107    #[error("method name {name:?} has an empty segment")]
108    NoSegment {
109        /// The rejected name.
110        name: String,
111    },
112    /// A segment had surrounding whitespace.
113    #[error("method name {name:?} has a segment with surrounding whitespace")]
114    Whitespace {
115        /// The rejected name.
116        name: String,
117    },
118}
119
120/// The parameters of a request or notification.
121///
122/// Opaque by design — see the module docs. An empty bag means "no parameters
123/// the IR models", which is the common case and not the same as "no
124/// parameters".
125#[derive(Debug, Clone, PartialEq, Default)]
126pub struct IrParams {
127    entries: Extensions,
128}
129
130impl IrParams {
131    /// No modelled parameters.
132    #[must_use]
133    pub const fn empty() -> Self {
134        Self {
135            entries: Extensions::empty(),
136        }
137    }
138
139    /// Wraps an extension bag as parameters.
140    #[must_use]
141    pub const fn from_extensions(entries: Extensions) -> Self {
142        Self { entries }
143    }
144
145    /// Adds a parameter.
146    #[must_use]
147    pub fn with(
148        mut self,
149        name: impl Into<String>,
150        value: crate::domain::ir::ExtensionValue,
151    ) -> Self {
152        self.entries.insert(name, value);
153        self
154    }
155
156    /// A parameter, if present.
157    #[must_use]
158    pub fn get(&self, name: &str) -> Option<&crate::domain::ir::ExtensionValue> {
159        self.entries.get(name)
160    }
161
162    /// The underlying bag.
163    #[must_use]
164    pub const fn as_extensions(&self) -> &Extensions {
165        &self.entries
166    }
167}
168
169/// A request. Expects exactly one result or error.
170#[derive(Debug, Clone, PartialEq)]
171pub struct IrRequest {
172    /// The method being called.
173    pub method: MethodName,
174    /// The request id, `None` only for a request the peer malformed.
175    pub id: Option<RequestId>,
176    /// The parameters.
177    pub params: IrParams,
178    /// Unmodelled fields on the envelope itself.
179    pub extensions: Extensions,
180}
181
182impl IrRequest {
183    /// A request with no parameters.
184    #[must_use]
185    pub const fn new(method: MethodName, id: RequestId) -> Self {
186        Self {
187            method,
188            id: Some(id),
189            params: IrParams::empty(),
190            extensions: Extensions::empty(),
191        }
192    }
193
194    /// Attaches parameters.
195    #[must_use]
196    pub fn with_params(mut self, params: IrParams) -> Self {
197        self.params = params;
198        self
199    }
200}
201
202/// A notification. Never has an id, never gets a reply.
203#[derive(Debug, Clone, PartialEq)]
204pub struct IrNotification {
205    /// The notification's method.
206    pub method: MethodName,
207    /// The parameters.
208    pub params: IrParams,
209    /// Unmodelled fields.
210    pub extensions: Extensions,
211}
212
213impl IrNotification {
214    /// A notification with no parameters.
215    ///
216    /// # Panics
217    ///
218    /// Never: the method is supplied by the caller and a non-notification
219    /// method is a caller mistake that surfaces as a test failure, not a
220    /// production panic. Use `Self::try_new` to get a `Result` instead.
221    #[must_use]
222    pub fn new(method: MethodName) -> Self {
223        debug_assert!(
224            method.is_notification(),
225            "{method} is not a notification method",
226        );
227        Self {
228            method,
229            params: IrParams::empty(),
230            extensions: Extensions::empty(),
231        }
232    }
233}
234
235/// A successful result.
236#[derive(Debug, Clone, PartialEq)]
237pub struct IrResult {
238    /// The request this answers.
239    pub id: RequestId,
240    /// The result payload, as modelled fields plus the passthrough bag.
241    pub params: IrParams,
242    /// Unmodelled fields.
243    pub extensions: Extensions,
244}
245
246impl IrResult {
247    /// A result with no modelled payload.
248    #[must_use]
249    pub const fn new(id: RequestId) -> Self {
250        Self {
251            id,
252            params: IrParams::empty(),
253            extensions: Extensions::empty(),
254        }
255    }
256}
257
258/// A JSON-RPC error code.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
260pub enum IrErrorCode {
261    /// `-32700`, invalid JSON.
262    ParseError,
263    /// `-32600`, the envelope was not a valid request object.
264    InvalidRequest,
265    /// `-32601`, the method does not exist.
266    MethodNotFound,
267    /// `-32602`, the params did not match the method.
268    InvalidParams,
269    /// `-32603`, an internal error.
270    InternalError,
271    /// A code in the implementation-defined `-32000..=-32099` range.
272    ///
273    /// The raw code, not an offset into the range. Storing the code means
274    /// `code` and `from_code` are both an identity, so there is no arithmetic
275    /// to get backwards and no narrowing cast to make: the range runs
276    /// *downwards* from `-32000`, and a representation that hid that as an
277    /// `offset` is exactly the kind of thing that ends up adding instead of
278    /// subtracting and putting offset 99 at `-31901`, outside the reserved
279    /// range.
280    Implementation(i64),
281    /// A code outside every range the spec reserves.
282    Other(i64),
283}
284
285impl IrErrorCode {
286    /// `-32022`, SEP-2575's unsupported-protocol-version code.
287    ///
288    /// Named rather than written at each use because the specification gives
289    /// it meaning rather than just a number: it is the code that carries
290    /// `data.supported`, so a client knows to retry against a revision this
291    /// server speaks instead of hunting for a bug in its own request. A bare
292    /// `-32022` at a call site reads as a typo, and this one is easy to typo.
293    ///
294    /// It lives in the implementation-defined range, which is why it is
295    /// [`Implementation`](Self::Implementation) and not a named variant of its
296    /// own: a new variant would be a promise that no other code can hold,
297    /// since nothing stops a peer sending this number for an unrelated reason.
298    pub const UNSUPPORTED_PROTOCOL_VERSION: Self = Self::Implementation(-32022);
299
300    /// `-32020`, `HeaderMismatchError` (`2026-07-28`).
301    ///
302    /// An HTTP header disagrees with the body it describes, or a required one
303    /// is missing. Renumbered from `-32001` when the revision partitioned the
304    /// server-error range; see that revision's changelog, minor change 12.
305    pub const HEADER_MISMATCH: Self = Self::Implementation(-32020);
306
307    /// `-32021`, `MissingRequiredClientCapabilityError` (`2026-07-28`).
308    ///
309    /// Serving the request needs a capability the client did not declare in
310    /// its per-request `clientCapabilities`. Carries
311    /// `data.requiredCapabilities`, a `ClientCapabilities` object.
312    pub const MISSING_REQUIRED_CLIENT_CAPABILITY: Self = Self::Implementation(-32021);
313
314    /// The numeric wire value.
315    #[must_use]
316    pub const fn code(self) -> i64 {
317        match self {
318            Self::ParseError => -32700,
319            Self::InvalidRequest => -32600,
320            Self::MethodNotFound => -32601,
321            Self::InvalidParams => -32602,
322            Self::InternalError => -32603,
323            // Both carry the code verbatim; the distinction is semantic.
324            Self::Implementation(code) | Self::Other(code) => code,
325        }
326    }
327
328    /// The code a raw number denotes.
329    ///
330    /// Not `const`: `RangeInclusive::contains` is not a const fn on 1.98, and
331    /// nothing evaluates this at compile time.
332    #[must_use]
333    pub fn from_code(code: i64) -> Self {
334        match code {
335            -32700 => Self::ParseError,
336            -32600 => Self::InvalidRequest,
337            -32601 => Self::MethodNotFound,
338            -32602 => Self::InvalidParams,
339            -32603 => Self::InternalError,
340            code if (-32099..=-32000).contains(&code) => Self::Implementation(code),
341            other => Self::Other(other),
342        }
343    }
344}
345
346/// An error result.
347#[derive(Debug, Clone, PartialEq)]
348pub struct IrError {
349    /// The request this answers.
350    pub id: Option<RequestId>,
351    /// The error code.
352    pub code: IrErrorCode,
353    /// A short human-readable message.
354    pub message: String,
355    /// Structured detail, if any.
356    pub data: Option<crate::domain::ir::JsonObject>,
357    /// Unmodelled fields.
358    pub extensions: Extensions,
359}
360
361impl IrError {
362    /// An error with a code and a message.
363    #[must_use]
364    pub fn new(id: Option<RequestId>, code: IrErrorCode, message: impl Into<String>) -> Self {
365        Self {
366            id,
367            code,
368            message: message.into(),
369            data: None,
370            extensions: Extensions::empty(),
371        }
372    }
373}
374
375/// Any message in either direction.
376#[derive(Debug, Clone, PartialEq)]
377pub enum IrMessage {
378    /// A request.
379    Request(IrRequest),
380    /// A notification.
381    Notification(IrNotification),
382    /// A result.
383    Result(IrResult),
384    /// An error.
385    Error(IrError),
386}
387
388impl IrMessage {
389    /// The method name, for a request or notification.
390    #[must_use]
391    pub const fn method(&self) -> Option<&MethodName> {
392        match self {
393            Self::Request(request) => Some(&request.method),
394            Self::Notification(notification) => Some(&notification.method),
395            Self::Result(_) | Self::Error(_) => None,
396        }
397    }
398
399    /// The request id, when there is one.
400    ///
401    /// A notification has none, and neither does a parse error, which is why
402    /// this is an `Option` on the message rather than a field.
403    #[must_use]
404    pub const fn id(&self) -> Option<&RequestId> {
405        match self {
406            Self::Request(request) => request.id.as_ref(),
407            Self::Notification(_) => None,
408            Self::Result(result) => Some(&result.id),
409            Self::Error(error) => error.id.as_ref(),
410        }
411    }
412
413    /// Whether this message expects a reply.
414    #[must_use]
415    pub const fn expects_reply(&self) -> bool {
416        matches!(self, Self::Request(_))
417    }
418
419    /// The features this message needs in order to be expressed.
420    ///
421    /// Empty for everything in the envelope: none of the four JSON-RPC shapes
422    /// are version-gated, because the spec's lifecycle is the same at every
423    /// revision. The method does the gating instead, and T08's codec table
424    /// maps a method to the features its payload needs.
425    #[must_use]
426    pub const fn required_features(&self) -> FeatureSet {
427        FeatureSet::empty()
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    #![expect(
434        clippy::unwrap_used,
435        reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
436    )]
437
438    use super::{
439        IrError, IrErrorCode, IrMessage, IrNotification, IrParams, IrRequest, IrResult, MethodName,
440        MethodNameError,
441    };
442    use crate::domain::RequestId;
443    use crate::domain::ir::ExtensionValue;
444
445    fn id() -> RequestId {
446        RequestId::number(1)
447    }
448
449    #[test]
450    fn method_names_validate_their_shape() {
451        assert!(MethodName::new("tools/list").is_ok());
452        assert!(MethodName::new("notifications/initialized").is_ok());
453        assert!(MethodName::new("ping").is_ok());
454
455        assert_eq!(MethodName::new("").unwrap_err(), MethodNameError::Empty);
456        assert_eq!(
457            MethodName::new("/tools/list").unwrap_err(),
458            MethodNameError::NoSegment {
459                name: "/tools/list".into()
460            }
461        );
462        assert_eq!(
463            MethodName::new("tools/list/").unwrap_err(),
464            MethodNameError::NoSegment {
465                name: "tools/list/".into()
466            }
467        );
468        assert_eq!(
469            MethodName::new("tools/ list").unwrap_err(),
470            MethodNameError::Whitespace {
471                name: "tools/ list".into()
472            }
473        );
474        assert_eq!(
475            MethodName::new("tools/list ").unwrap_err(),
476            MethodNameError::Whitespace {
477                name: "tools/list ".into()
478            }
479        );
480    }
481
482    #[test]
483    fn method_names_round_trip() {
484        let name = MethodName::new("resources/read").unwrap();
485        assert_eq!(name.as_str(), "resources/read");
486        assert_eq!(name.to_string(), "resources/read");
487        assert_eq!(
488            "tools/call".parse::<MethodName>().unwrap().as_str(),
489            "tools/call"
490        );
491    }
492
493    #[test]
494    fn the_notification_prefix_is_the_convention() {
495        assert!(
496            MethodName::new("notifications/cancelled")
497                .unwrap()
498                .is_notification()
499        );
500        assert!(!MethodName::new("tools/list").unwrap().is_notification());
501        // A method that merely starts with the same letters is not one.
502        assert!(
503            !MethodName::new("notificationsish/x")
504                .unwrap()
505                .is_notification()
506        );
507    }
508
509    #[test]
510    fn params_hold_unmodelled_fields() {
511        let params = IrParams::empty()
512            .with("cursor", ExtensionValue::String("abc".into()))
513            .with("n", ExtensionValue::Number("5".into()));
514        assert_eq!(
515            params.get("cursor"),
516            Some(&ExtensionValue::String("abc".into()))
517        );
518        assert!(params.get("missing").is_none());
519    }
520
521    #[test]
522    fn requests_carry_a_method_an_id_and_params() {
523        let request = IrRequest::new(MethodName::new("tools/call").unwrap(), id())
524            .with_params(IrParams::empty().with("name", ExtensionValue::String("t".into())));
525        assert_eq!(request.method.as_str(), "tools/call");
526        assert_eq!(request.id.as_ref(), Some(&id()));
527        assert!(request.params.get("name").is_some());
528    }
529
530    #[test]
531    fn notifications_have_no_id_and_expects_reply_is_false() {
532        let notification =
533            IrNotification::new(MethodName::new("notifications/initialized").unwrap());
534        let message = IrMessage::Notification(notification);
535        assert!(message.id().is_none());
536        assert!(!message.expects_reply());
537        assert_eq!(
538            message.method().unwrap().as_str(),
539            "notifications/initialized"
540        );
541    }
542
543    #[test]
544    fn a_request_expects_a_reply_and_carries_its_id() {
545        let message = IrMessage::Request(IrRequest::new(MethodName::new("ping").unwrap(), id()));
546        assert!(message.expects_reply());
547        assert_eq!(message.id(), Some(&id()));
548    }
549
550    #[test]
551    fn a_result_points_back_at_its_request() {
552        let result = IrResult::new(id());
553        let message = IrMessage::Result(result);
554        assert_eq!(message.id(), Some(&id()));
555        assert!(!message.expects_reply());
556        assert!(message.method().is_none());
557    }
558
559    /// A parse error has no id, which is why `IrError::id` is an `Option`
560    /// rather than a plain field.
561    #[test]
562    fn a_parse_error_may_have_no_id() {
563        let error = IrError::new(None, IrErrorCode::ParseError, "bad json");
564        let message = IrMessage::Error(error);
565        assert!(message.id().is_none());
566        assert!(!message.expects_reply());
567    }
568
569    #[test]
570    fn an_error_with_an_id_points_back_at_its_request() {
571        let error = IrError::new(Some(id()), IrErrorCode::InvalidParams, "nope");
572        let message = IrMessage::Error(error);
573        assert_eq!(message.id(), Some(&id()));
574    }
575
576    /// Every reserved JSON-RPC code, and the implementation range, round trip.
577    #[test]
578    fn error_codes_round_trip() {
579        for code in [
580            IrErrorCode::ParseError,
581            IrErrorCode::InvalidRequest,
582            IrErrorCode::MethodNotFound,
583            IrErrorCode::InvalidParams,
584            IrErrorCode::InternalError,
585        ] {
586            assert_eq!(IrErrorCode::from_code(code.code()), code, "{code:?}");
587        }
588        for raw in [-32000_i64, -32001, -32050, -32099] {
589            let code = IrErrorCode::Implementation(raw);
590            assert_eq!(code.code(), raw);
591            assert_eq!(IrErrorCode::from_code(raw), code);
592        }
593        // The whole reserved range maps to `Implementation`, and nothing
594        // outside it does.
595        for raw in -32099_i64..=-32000 {
596            assert!(
597                matches!(IrErrorCode::from_code(raw), IrErrorCode::Implementation(_)),
598                "{raw}",
599            );
600        }
601    }
602
603    #[test]
604    fn a_code_outside_every_reserved_range_is_other() {
605        for raw in [0_i64, 1, -1, -31999, 12345] {
606            assert_eq!(
607                IrErrorCode::from_code(raw),
608                IrErrorCode::Other(raw),
609                "{raw}",
610            );
611            assert_eq!(IrErrorCode::Other(raw).code(), raw);
612        }
613    }
614
615    /// The envelope is not version-gated in any of its four shapes, so
616    /// lowering never has to ask about the message kind.
617    #[test]
618    fn no_envelope_shape_requires_a_feature() {
619        let messages = [
620            IrMessage::Request(IrRequest::new(MethodName::new("ping").unwrap(), id())),
621            IrMessage::Notification(IrNotification::new(
622                MethodName::new("notifications/initialized").unwrap(),
623            )),
624            IrMessage::Result(IrResult::new(id())),
625            IrMessage::Error(IrError::new(Some(id()), IrErrorCode::InternalError, "x")),
626        ];
627        for message in messages {
628            assert!(message.required_features().is_empty(), "{message:?}");
629        }
630    }
631}