Skip to main content

protocol_droid/adapters/primary/transport/http/
sse.rs

1//! The `text/event-stream` frame format, with no HTTP in it.
2//!
3//! # What this is for
4//!
5//! MCP's Streamable HTTP transport carries server-initiated messages as SSE.
6//! The framing is small, the standard is precise about it, and every part of
7//! it that is easy to get wrong is silent when it is wrong: a stream that
8//! mis-frames still *looks* like a working stream until the one payload that
9//! contains a newline arrives. So the format lives in its own file with no
10//! `axum` in it, and the tests below are about the format rather than about
11//! HTTP.
12//!
13//! The reference throughout is the WHATWG HTML standard's event-stream parsing
14//! rules, which is the definition MCP's transport inherits. The three that
15//! bite hardest:
16//!
17//! 1. **A line terminator is CRLF, CR or LF**, not just LF. A lone CR inside a
18//!    `data:` line is a frame boundary to a conforming client, so an encoder
19//!    that only splits on `\n` will happily emit a frame a client reads as two.
20//! 2. **A newline in a payload is carried by repeating the field name.**
21//!    `data: one\ndata: two\n\n` is one event whose data is `one\ntwo`. A raw
22//!    newline in a `data:` line is not a payload character, it is the end of
23//!    that line — so embedding one truncates the payload at the first newline
24//!    and the client dispatches a short event. This is the single most common
25//!    SSE encoder bug, and it corrupts a tool result that happens to contain
26//!    one.
27//! 3. **A line beginning with `:` is a comment** and dispatches nothing. That
28//!    is how a keep-alive is expressed, and a decoder that turns a comment
29//!    into an empty event hands its client a stream of empty messages.
30//!
31//! # Why the payload is a `serde_json::Value` and not a `String`
32//!
33//! Because the obvious shortcut is wrong in a way no test of the *transport*
34//! will catch. `format!("{value:?}")` on a JSON string containing U+007F emits
35//! `\u{7f}`, which is Rust's `Debug` escaping and is **not** JSON — a client
36//! parsing the event fails, or worse, parses a truncated one. [`SseEvent::json`]
37//! takes the value and serialises it with `serde_json`, so the only way to
38//! build a frame from data is the way that produces legal JSON.
39//!
40//! # Round-trip, stated honestly
41//!
42//! [`encode`](SseEvent::encode) followed by [`decode`](SseEvent::decode) returns
43//! the original payload exactly for any text with no CR in it. A CR does not
44//! round-trip and cannot: the format has no escape for it, and a conformant
45//! reader treats it as a line terminator, so the only faithful encoding of a
46//! CR is to break the line and rejoin with LF. JSON never contains a raw CR —
47//! `serde_json` escapes it as `\r` inside the string — so this is a statement
48//! about the format's limits, not about a live loss. The test that proves it is
49//! [`a_carriage_return_is_a_line_terminator`](self::tests::a_carriage_return_is_a_line_terminator).
50
51use core::fmt::Write as _;
52use core::ops::ControlFlow;
53use core::str::FromStr;
54
55use serde_json::Value;
56use thiserror::Error;
57
58use crate::domain::{EventId, EventName, EventNameError};
59
60/// The field name carrying the event id a client resumes from.
61const ID_FIELD: &str = "id";
62
63/// The field name carrying the payload.
64const DATA_FIELD: &str = "data";
65
66/// The field name carrying the event's name.
67const EVENT_FIELD: &str = "event";
68
69/// One `text/event-stream` frame.
70///
71/// `id` is optional because the format says so: a frame without an `id:` is
72/// legal SSE and dispatches normally, it simply does not move the client's
73/// resume cursor. That is the shape a keep-alive-adjacent event takes, and an
74/// encoder that always wrote an `id` would either invent a cursor position
75/// that never existed or refuse to emit the frame at all.
76///
77/// `name` is optional for the same reason, and for a sharper one: Streamable
78/// HTTP never sets it — every frame on that transport is a message and the
79/// name carries nothing. The `2024-11-05` transport names *every* frame, and a
80/// client that dispatches on the name is the only way to tell an `endpoint`
81/// frame (where to POST) from a `message` frame (a JSON-RPC payload). A codec
82/// that dropped the name would parse a legacy stream perfectly and hand the
83/// client two indistinguishable event kinds.
84///
85/// A frame *with* no `data` does not exist as a value: the standard dispatches
86/// nothing for such a block, so [`decode`](Self::decode) reports
87/// [`SseError::NoData`] and [`dispatch`](Self::dispatch) reports `None`.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct SseEvent {
90    name: Option<EventName>,
91    id: Option<EventId>,
92    data: String,
93}
94
95impl SseEvent {
96    /// A frame with a resume cursor.
97    #[must_use]
98    pub const fn new(id: EventId, data: String) -> Self {
99        Self {
100            name: None,
101            id: Some(id),
102            data,
103        }
104    }
105
106    /// A frame with no resume cursor, which leaves the client's where it was.
107    #[must_use]
108    pub const fn without_id(data: String) -> Self {
109        Self {
110            name: None,
111            id: None,
112            data,
113        }
114    }
115
116    /// A named frame, for the transports that dispatch on the name.
117    ///
118    /// Takes an [`EventName`] rather than a `&str` because a name is spliced
119    /// into a line and a line can be terminated from inside it — the injection
120    /// is a parse away, and [`EventName`] is the parse that closes it.
121    /// Constructed from a `&'static str` the way the legacy transport's two
122    /// names are; a dynamic name goes through [`EventName::from_str`].
123    ///
124    /// A named frame carries no resume cursor, and that is a property of the
125    /// transport rather than a restriction: `2024-11-05` has no `Last-Event-ID`
126    /// and no replay, so a name is never combined with a cursor on this
127    /// transport. The two are still representable together for a future
128    /// transport that does name and resume at once.
129    #[must_use]
130    pub const fn named(name: EventName, data: String) -> Self {
131        Self {
132            name: Some(name),
133            id: None,
134            data,
135        }
136    }
137
138    /// A named frame that also carries a resume cursor.
139    #[must_use]
140    pub const fn named_with_id(name: EventName, id: EventId, data: String) -> Self {
141        Self {
142            name: Some(name),
143            id: Some(id),
144            data,
145        }
146    }
147
148    /// The frame's name, if it has one.
149    #[must_use]
150    pub const fn name(&self) -> Option<&EventName> {
151        self.name.as_ref()
152    }
153
154    /// Whether this frame is the named `event`.
155    #[must_use]
156    pub fn is_named(&self, event: &EventName) -> bool {
157        self.name.as_ref().is_some_and(|name| name.is(event))
158    }
159
160    /// A frame whose data is `value` serialised as JSON.
161    ///
162    /// The only constructor that takes structured data, and the reason the
163    /// `Debug`-formatting bug is unreachable rather than merely discouraged —
164    /// see the module docs.
165    ///
166    /// # Errors
167    ///
168    /// [`SseError::Unserialisable`] if the value cannot be serialised, which
169    /// for a [`Value`] means a non-string map key. The payload is the message
170    /// the peer receives, so an event that cannot be encoded must not be
171    /// encoded wrong.
172    pub fn json(id: EventId, value: &Value) -> Result<Self, SseError> {
173        let data = serde_json::to_string(value).map_err(|_| SseError::Unserialisable)?;
174        Ok(Self::new(id, data))
175    }
176
177    /// The resume cursor, if this frame carries one.
178    #[must_use]
179    pub const fn id(&self) -> Option<EventId> {
180        self.id
181    }
182
183    /// The payload, with the frame's line structure already undone.
184    #[must_use]
185    pub fn data(&self) -> &str {
186        &self.data
187    }
188
189    /// Consumes the frame, yielding the payload.
190    ///
191    /// The id is dropped rather than returned because the write path uses the
192    /// payload and the resume cursor comes from the ring that produced the
193    /// event; a caller that wanted both has the [`StreamEvent`](crate::domain::StreamEvent)
194    /// it came from.
195    #[must_use]
196    pub fn into_data(self) -> String {
197        self.data
198    }
199
200    /// The bytes to write, without a trailing flush.
201    ///
202    /// `event: <name>` and `id: <n>` first when present, then one `data: `
203    /// line per line of the payload, then the blank line that dispatches the
204    /// event. A payload with no newline in it is therefore exactly one `data:`
205    /// line, and one with *k* newlines is *k + 1* — which is the claim
206    /// `a_newline_in_the_payload_becomes_another_data_line`
207    /// exists to hold.
208    ///
209    /// Field order is not load-bearing: the standard dispatches on field
210    /// *names*, and this decoder does the same. `event` goes first because that
211    /// is the order the WHATWG examples use, and a reader that happened to care
212    /// about order would at least be agreeing with the examples.
213    #[must_use]
214    pub fn encode(&self) -> String {
215        let mut out = String::with_capacity(self.data.len() + 32);
216        if let Some(name) = &self.name {
217            // `EventName`'s parse refuses CR and LF, so this cannot be a
218            // second field. That is the whole reason it is a newtype and the
219            // whole reason the write is unchecked.
220            let _ = writeln!(out, "{EVENT_FIELD}: {name}");
221        }
222        if let Some(id) = self.id {
223            // `EventId` is a `u64` and `Display` writes decimal, so this
224            // cannot fail and cannot produce anything a header would reject.
225            let _ = writeln!(out, "{ID_FIELD}: {id}");
226        }
227        for_each_line(&self.data, |line| {
228            out.push_str(DATA_FIELD);
229            out.push_str(": ");
230            out.push_str(line);
231            out.push('\n');
232            ControlFlow::Continue(())
233        })
234        .ok();
235        // The blank line is what dispatches the event. Without it the client
236        // buffers the frame indefinitely and the stream looks alive but is not.
237        out.push('\n');
238        out
239    }
240
241    /// Parses one frame's block — its lines, without the trailing blank.
242    ///
243    /// # Errors
244    ///
245    /// [`SseError::NoData`] if the block carries no `data:` field,
246    /// [`SseError::MalformedId`] if an `id:` field is not a decimal event id,
247    /// and [`SseError::MalformedName`](SseError::MalformedName) if an `event:`
248    /// field is not a usable name.
249    pub fn decode(block: &str) -> Result<Self, SseError> {
250        Self::dispatch(block)?.ok_or(SseError::NoData)
251    }
252
253    /// The standard's dispatch, which dispatches *nothing* for some blocks.
254    ///
255    /// Returns `None` for a block that is empty or is entirely comments, and
256    /// that is the load-bearing difference from [`decode`](Self::decode): a
257    /// keep-alive comment is a legal block that produces no event, and a stream
258    /// loop that treated it as one would hand its client an empty message
259    /// every heartbeat.
260    ///
261    /// # Errors
262    ///
263    /// [`SseError::MalformedId`] if an `id:` field is not a decimal event id.
264    /// A bad cursor is an error rather than a zero: `EventId`'s `0` is a real,
265    /// meaningful watermark meaning "before the first event", and quietly
266    /// reading a corrupt id as that would tell a client it has seen everything
267    /// when it has seen nothing.
268    ///
269    /// [`SseError::MalformedName`] if an `event:` field is empty or contains a
270    /// line terminator. An empty one is refused for the same reason a zero id
271    /// is not read as a watermark: `event:` with no value means the *default*
272    /// name, and treating it as a name of its own would make `Some("")` and
273    /// `None` two spellings of one dispatch.
274    pub fn dispatch(block: &str) -> Result<Option<Self>, SseError> {
275        let mut name: Option<EventName> = None;
276        let mut id: Option<EventId> = None;
277        // Accumulated as a `Vec<&str>` of lines and joined once, rather than
278        // pushed as a running string: the join is the only place a separator
279        // can be wrong, and a single `join` makes it one place.
280        let mut data: Vec<&str> = Vec::new();
281
282        for_each_line(block, |line| {
283            // A leading colon is a comment, by definition. The standard says so
284            // before it says anything about fields, and it is how keep-alives
285            // are written.
286            if line.starts_with(':') {
287                return ControlFlow::Continue(());
288            }
289            // A line with no colon is a field name with an empty value, which
290            // the standard defines rather than treats as malformed.
291            let (field, value) = match line.split_once(':') {
292                Some((field, value)) => (
293                    field,
294                    // Exactly one leading space is part of the framing, not the
295                    // value. A second one is the payload's, and a session id
296                    // with a stray space in it is a client's typo to report
297                    // rather than to paper over here.
298                    value.strip_prefix(' ').unwrap_or(value),
299                ),
300                None => (line, ""),
301            };
302            match field {
303                ID_FIELD => match parse_event_id(value) {
304                    Ok(parsed) => id = Some(parsed),
305                    // Stop at the first bad field rather than carrying on: a
306                    // block with a corrupt cursor is not a frame this decoder
307                    // is entitled to interpret, because the cursor decides what
308                    // the client believes it has already received.
309                    Err(error) => return ControlFlow::Break(error),
310                },
311                EVENT_FIELD => match EventName::from_str(value) {
312                    Ok(parsed) => name = Some(parsed),
313                    Err(error) => {
314                        return ControlFlow::Break(SseError::MalformedName(error));
315                    }
316                },
317                DATA_FIELD => data.push(value),
318                // `retry` and anything a future revision adds are ignored, per
319                // the standard. MCP does not use them. `event` used to be in
320                // this arm too, on the grounds that MCP did not use it — which
321                // was true of the Streamable HTTP transport and false of
322                // `2024-11-05`, where every frame is named and the name is the
323                // only thing that distinguishes an `endpoint` frame from a
324                // `message` one.
325                _ => {}
326            }
327            ControlFlow::Continue(())
328        })?;
329
330        if data.is_empty() {
331            return Ok(None);
332        }
333        Ok(Some(Self {
334            name,
335            id,
336            data: data.join("\n"),
337        }))
338    }
339}
340
341/// Reads an `id:` field value as an event id.
342///
343/// The only field MCP writes is a decimal `u64`, which is exactly what
344/// [`EventId`]'s `Display` produces, so a value that does not parse came from
345/// somewhere other than this encoder — a proxy that rewrote the header, a
346/// client that invented one, a future revision using a different scheme. All
347/// three are worth reporting rather than guessing at.
348fn parse_event_id(value: &str) -> Result<EventId, SseError> {
349    u64::from_str(value).map_or(Err(SseError::MalformedId), |id| Ok(EventId::new(id)))
350}
351
352/// Calls `visit` once per line, treating CRLF, CR and LF as terminators.
353///
354/// The CR arm is the one worth reading twice. A client that splits only on LF
355/// will see a lone CR as an ordinary character and keep the rest of the line,
356/// while this function and any conforming reader treat it as a break — so an
357/// encoder that ignored it would produce a frame the two sides read
358/// differently. The frame is already written, so nothing here can recover.
359///
360/// The index only ever advances past ASCII bytes, so every range it produces
361/// lands on a UTF-8 character boundary and `str::get` cannot return `None`
362/// here. It is still a `get` rather than a slice: a range index that is wrong
363/// by one is a panic on a live stream, and the cost of the alternative is a
364/// branch the optimiser removes.
365/// Returns the error the visitor broke with, or `Ok(())` if it ran to the end.
366///
367/// A `ControlFlow` rather than a plain `for_each` so a parse failure inside
368/// the visitor can stop the walk: the `id:` field is parsed as it is met, and
369/// continuing past a corrupt one to collect the rest of the block would do
370/// work whose result is already discarded.
371///
372/// The `for<'a>` on the visitor is what lets a caller push the borrowed lines
373/// out of the closure — the parameter's lifetime is the *input's*, not a fresh
374/// anonymous one, so a `&str` handed to the visitor is still a `&str` the
375/// caller can keep.
376fn for_each_line<'i>(
377    input: &'i str,
378    mut visit: impl FnMut(&'i str) -> ControlFlow<SseError>,
379) -> Result<(), SseError> {
380    let mut start = 0;
381    let mut index = 0;
382    while let Some(byte) = input.as_bytes().get(index) {
383        match *byte {
384            b'\n' => {
385                if let Some(line) = input.get(start..index)
386                    && let ControlFlow::Break(error) = visit(line)
387                {
388                    return Err(error);
389                }
390                index += 1;
391                start = index;
392            }
393            b'\r' => {
394                if let Some(line) = input.get(start..index)
395                    && let ControlFlow::Break(error) = visit(line)
396                {
397                    return Err(error);
398                }
399                index += 1;
400                // CRLF is one terminator, not two. Skipping the LF is what
401                // stops a CRLF-encoded payload from gaining a blank line per
402                // line and dispatching an empty event for each.
403                if input.as_bytes().get(index) == Some(&b'\n') {
404                    index += 1;
405                }
406                start = index;
407            }
408            _ => index += 1,
409        }
410    }
411    // The trailing piece is emitted even when it is empty, and that is
412    // load-bearing in both directions. Emptily: an empty payload is one
413    // empty `data:` line, so without this a payload of `""` would encode to a
414    // frame the decoder refuses as carrying no data at all. Trailingly: a
415    // payload ending in a newline has an empty *last* line, which is how the
416    // final newline survives the trip — `str::split` semantics, and the same
417    // rule the decoder's buffer-then-strip-one-LF arithmetic assumes.
418    if let Some(last) = input.get(start..)
419        && let ControlFlow::Break(error) = visit(last)
420    {
421        return Err(error);
422    }
423    Ok(())
424}
425
426/// Why a frame could not be read or written.
427///
428/// The variants are what a caller can act on separately: a frame that cannot be
429/// serialised is this process's bug, a corrupt id is the peer's, and a block
430/// with no data is not a failure at all — the standard simply dispatches
431/// nothing for it.
432#[derive(Debug, Clone, PartialEq, Eq, Error)]
433pub enum SseError {
434    /// The block carried no `data:` field, so the standard dispatches no event.
435    #[error("this block carries no data field, so it dispatches no event")]
436    NoData,
437
438    /// An `id:` field was not a decimal event id.
439    ///
440    /// Carries nothing from the input on purpose. The value came from a peer,
441    /// it may be arbitrarily long, and a refusal that echoes it would be
442    /// reflecting untrusted input into a response body for no diagnostic gain:
443    /// the peer knows what it sent.
444    #[error("the id field is not a decimal event id")]
445    MalformedId,
446
447    /// An `event:` field was not a usable event name.
448    ///
449    /// Carries the [`EventNameError`] because the two reasons are worth telling
450    /// apart when reading a log: an empty name is a peer that wrote
451    /// `event:` and nothing else, and a name holding a line terminator is a
452    /// peer — or something pretending to be one — that tried to smuggle a
453    /// second field through a frame. Neither echoes the offending value, for
454    /// the reason [`MalformedId`](Self::MalformedId) does not.
455    #[error("the event field is not a usable event name: {0}")]
456    MalformedName(EventNameError),
457
458    /// The value has a map key that is not a string, so it has no JSON form.
459    #[error("the payload cannot be serialised as JSON")]
460    Unserialisable,
461}
462
463#[cfg(test)]
464mod tests {
465    use serde_json::{Value, json};
466
467    use super::{SseError, SseEvent, for_each_line};
468    use crate::domain::{EventId, EventName, EventNameError, names};
469    use core::str::FromStr;
470
471    type TestError = Box<dyn std::error::Error>;
472
473    /// Collects the lines `for_each_line` reports, for the terminator tests.
474    fn split(input: &str) -> Vec<&str> {
475        let mut lines = Vec::new();
476        let _ = for_each_line(input, |line| {
477            lines.push(line);
478            core::ops::ControlFlow::Continue(())
479        });
480        lines
481    }
482
483    /// One `data:` line, when the payload has no newline in it.
484    ///
485    /// This is the baseline the newline test is measured against: an encoder
486    /// that always wrote a fixed number of `data:` lines would satisfy a
487    /// "contains `data:`" assertion and still be wrong.
488    #[test]
489    fn a_single_line_payload_is_one_data_line() {
490        let frame = SseEvent::new(EventId::new(7), r#"{"jsonrpc":"2.0"}"#.to_owned());
491        assert_eq!(frame.encode(), "id: 7\ndata: {\"jsonrpc\":\"2.0\"}\n\n");
492    }
493
494    /// A newline in the payload becomes another `data:` line, never a raw one.
495    ///
496    /// The mutation this kills is the whole reason the module exists: writing
497    /// `data: one\ntwo\n\n` and calling it a frame. That output looks correct
498    /// in a log, and a conforming client reads it as a one-line event
499    /// followed by a field named `two` — so a tool result containing a newline
500    /// arrives truncated, with no error anywhere.
501    ///
502    /// The assertion is on the *count* of `data:` lines and on there being no
503    /// bare line between them, because either alone is satisfiable by an
504    /// encoder that merely happens to agree on this one input.
505    #[test]
506    fn a_newline_in_the_payload_becomes_another_data_line() {
507        let frame = SseEvent::new(EventId::new(1), "one\ntwo\nthree".to_owned());
508        let encoded = frame.encode();
509        assert_eq!(
510            encoded, "id: 1\ndata: one\ndata: two\ndata: three\n\n",
511            "a payload newline must be carried by repeating the field name"
512        );
513        assert_eq!(encoded.matches("data: ").count(), 3);
514        assert!(
515            !encoded.contains("one\ntwo"),
516            "a raw newline reached the wire inside a data line"
517        );
518    }
519
520    /// Encode then decode returns the payload, for the shapes that can.
521    ///
522    /// Table-driven because the interesting cases are unrelated to each other:
523    /// an empty payload, a payload that is only a newline, quotes and
524    /// backslashes, non-ASCII text, and a DEL. The last is the one that proves
525    /// the `Debug`-escaping bug is unreachable — `format!("{value:?}")` turns
526    /// U+007F into `\u{7f}`, which is not JSON, and the round trip here would
527    /// come back with those six literal characters in it.
528    #[test]
529    fn encode_then_decode_round_trips() -> Result<(), TestError> {
530        let payloads = [
531            String::new(),
532            "a".to_owned(),
533            "\n".to_owned(),
534            "\n\n".to_owned(),
535            "line one\nline two".to_owned(),
536            r#"{"a":"b\"c\\d"}"#.to_owned(),
537            "café — ünïcode".to_owned(),
538            "\u{7f}".to_owned(),
539            "trailing\n".to_owned(),
540        ];
541        for payload in payloads {
542            let frame = SseEvent::new(EventId::new(3), payload.clone());
543            let decoded = SseEvent::decode(&frame.encode())?;
544            assert_eq!(decoded.data(), payload, "round trip changed {payload:?}");
545            assert_eq!(decoded.id(), Some(EventId::new(3)));
546        }
547        Ok(())
548    }
549
550    /// A payload with k newlines is carried by k + 1 `data:` lines, always.
551    ///
552    /// The generalisation of the single case, checked at a size where an
553    /// off-by-one in the splitter would show up. A trailing newline is the
554    /// awkward one: it must produce a final *empty* `data:` line, or the
555    /// payload's final LF is lost.
556    #[test]
557    fn the_data_line_count_is_the_newline_count_plus_one() -> Result<(), TestError> {
558        for newlines in 0..5_usize {
559            // A payload with exactly `newlines` newline characters in it, so
560            // the loop variable is the count the invariant is about.
561            let payload = "x\n".repeat(newlines);
562            let encoded = SseEvent::without_id(payload.clone()).encode();
563            assert_eq!(
564                encoded.matches("data: ").count(),
565                newlines + 1,
566                "a payload of {newlines} newlines was mis-framed: {encoded:?}"
567            );
568            assert_eq!(SseEvent::decode(&encoded)?.data(), payload);
569        }
570        Ok(())
571    }
572
573    /// A lone CR is a line terminator on the wire and an LF after decoding.
574    ///
575    /// States the format's limit rather than hiding it: the round trip is exact
576    /// for LF and LF/CRLF text and *normalises* a bare CR, because the format
577    /// has no escape for one and a reader that treated it as data would
578    /// disagree with every other reader. JSON cannot contain a raw CR, so this
579    /// costs nothing in practice.
580    #[test]
581    fn a_carriage_return_is_a_line_terminator() -> Result<(), TestError> {
582        // Split semantics throughout: a trailing terminator leaves an empty
583        // final piece, which is exactly what carries the payload's last
584        // newline back out of the encoder.
585        assert_eq!(split("a\rb"), vec!["a", "b"]);
586        assert_eq!(split("a\r\nb"), vec!["a", "b"]);
587        assert_eq!(split("a\nb"), vec!["a", "b"]);
588        assert_eq!(split("a\n"), vec!["a", ""]);
589        assert_eq!(split("a\r\n"), vec!["a", ""]);
590        assert_eq!(split("a\r"), vec!["a", ""]);
591        assert_eq!(split("a\n\n"), vec!["a", "", ""]);
592        // No terminator at all is one line, and nothing at all is one empty one.
593        assert_eq!(split("a"), vec!["a"]);
594        assert_eq!(split(""), vec![""]);
595
596        let frame = SseEvent::without_id("one\rtwo".to_owned());
597        assert_eq!(frame.encode(), "data: one\ndata: two\n\n");
598        assert_eq!(SseEvent::decode(&frame.encode())?.data(), "one\ntwo");
599        Ok(())
600    }
601
602    /// CRLF does not add an empty data line per line.
603    ///
604    /// A decoder that split on CR and then on LF independently would see a
605    /// blank line between every pair and dispatch an empty event for each — a
606    /// stream where every real message is followed by a phantom one.
607    #[test]
608    fn crlf_encoded_frames_dispatch_exactly_one_event() -> Result<(), TestError> {
609        let frame = SseEvent::new(EventId::new(4), "one\ntwo".to_owned());
610        let crlf = frame.encode().replace('\n', "\r\n");
611        let decoded = SseEvent::decode(&crlf)?;
612        assert_eq!(decoded.data(), "one\ntwo");
613        assert_eq!(decoded.id(), Some(EventId::new(4)));
614        Ok(())
615    }
616
617    /// A frame with no `id:` is legal and decodes with no cursor.
618    ///
619    /// The standard does not require an id, and MCP's own streams do not always
620    /// have one to give. An encoder or decoder that insisted would either
621    /// invent a cursor position that never existed — and then a resume from it
622    /// would be refused as `Unknown` — or drop the frame.
623    #[test]
624    fn a_frame_with_no_id_decodes_with_no_id() -> Result<(), TestError> {
625        let frame = SseEvent::without_id("{\"a\":1}".to_owned());
626        let encoded = frame.encode();
627        assert_eq!(encoded, "data: {\"a\":1}\n\n");
628        let decoded = SseEvent::decode(&encoded)?;
629        assert_eq!(decoded.id(), None);
630        assert_eq!(decoded.data(), "{\"a\":1}");
631        Ok(())
632    }
633
634    /// A comment line dispatches nothing, and is not an empty event.
635    ///
636    /// This is the keep-alive. A server that writes `:` on an idle connection
637    /// is saying "still here" and must not produce an event; a decoder that
638    /// turned it into one would hand the client a message with no id and no
639    /// content on every heartbeat, forever.
640    #[test]
641    fn a_comment_line_dispatches_nothing() -> Result<(), TestError> {
642        for block in [":", ": keep-alive", "", "\n\n", ": one\n: two\n\n"] {
643            assert_eq!(
644                SseEvent::dispatch(block)?,
645                None,
646                "a comment-only block dispatched an event"
647            );
648            assert_eq!(SseEvent::decode(block), Err(SseError::NoData));
649        }
650        // And a comment inside a real frame is skipped without disturbing it.
651        let frame = SseEvent::decode("id: 2\n: a comment\ndata: payload")?;
652        assert_eq!(frame.id(), Some(EventId::new(2)));
653        assert_eq!(frame.data(), "payload");
654        Ok(())
655    }
656
657    /// A `Last-Event-ID` value that is not a number is an error, not a zero.
658    ///
659    /// The mutation this kills is a `unwrap_or_default()` on the parse, which
660    /// reads any corrupt cursor as `EventId::WATERMARK` — "before the first
661    /// event". That is a real, meaningful id, so the error would not surface:
662    /// the ring would be asked to replay from the start of time and answer
663    /// `UpToDate` for a client that has actually seen nothing, and the stream
664    /// would sit silent forever while the client believes it is current.
665    #[test]
666    fn a_corrupt_id_is_an_error_and_not_a_zero() -> Result<(), TestError> {
667        for block in [
668            "id: abc\ndata: x",
669            "id: \ndata: x",
670            "id: 12x\ndata: x",
671            "id: 1.0\ndata: x",
672            "id: 99999999999999999999\ndata: x",
673        ] {
674            assert_eq!(
675                SseEvent::decode(block),
676                Err(SseError::MalformedId),
677                "{block:?} was accepted"
678            );
679        }
680        // Zero, on the other hand, is a legitimate cursor and must survive.
681        assert_eq!(
682            SseEvent::decode("id: 0\ndata: x")?.id(),
683            Some(EventId::new(0))
684        );
685        Ok(())
686    }
687
688    /// A payload built from JSON is legal JSON on the wire.
689    ///
690    /// The DEL is the case: `serde_json` emits it raw, because U+007F is legal
691    /// inside a JSON string unescaped, and `format!("{value:?}")` would emit
692    /// `\u{7f}` — six characters that are not a DEL and are not valid JSON
693    /// escape syntax outside a Rust debug context. Asserting the round trip
694    /// through `serde_json::from_str` is what makes the claim about *validity*
695    /// rather than about *equality with a Debug string*.
696    #[test]
697    fn a_json_payload_is_parsed_as_json_by_a_reader() -> Result<(), TestError> {
698        let value = json!({"text": "before\u{7f}after", "n": 1});
699        let frame = SseEvent::json(EventId::new(9), &value)?;
700        let encoded = frame.encode();
701        assert!(
702            !encoded.contains("\\u{"),
703            "the payload carries Rust debug escaping: {encoded:?}"
704        );
705
706        let decoded = SseEvent::decode(&encoded)?;
707        let reparsed: Value = serde_json::from_str(decoded.data())?;
708        assert_eq!(reparsed, value);
709        Ok(())
710    }
711
712    /// Unknown fields are ignored, not preserved and not fatal.
713    ///
714    /// `event` and `retry` are part of the standard and MCP does not use them;
715    /// anything else is a future revision's field. The standard says to ignore
716    /// what you do not understand, and this is the assertion that the decoder
717    /// follows it — a decoder that errored would break against a conforming
718    /// client that sets `retry`.
719    #[test]
720    fn unknown_fields_are_ignored() -> Result<(), TestError> {
721        let frame =
722            SseEvent::decode("event: message\nretry: 5000\nid: 5\nwhatever: x\ndata: payload")?;
723        assert_eq!(frame.id(), Some(EventId::new(5)));
724        assert_eq!(frame.data(), "payload");
725        Ok(())
726    }
727
728    /// Only the framing space after the colon is stripped, never the payload's.
729    ///
730    /// One leading space belongs to the format. A second is data, and a payload
731    /// that begins with a space is common enough in JSON-adjacent text that
732    /// trimming it would corrupt the message. Symmetrically, a field with no
733    /// colon at all is a field with an empty value rather than a parse error,
734    /// which is what the standard specifies.
735    #[test]
736    fn exactly_one_space_after_the_colon_is_framing() -> Result<(), TestError> {
737        assert_eq!(SseEvent::decode("data:  two")?.data(), " two");
738        assert_eq!(SseEvent::decode("data:two")?.data(), "two");
739        assert_eq!(
740            SseEvent::dispatch("data")?,
741            Some(SseEvent::without_id(String::new()))
742        );
743        Ok(())
744    }
745
746    /// A frame survives being concatenated into a stream and read back whole.
747    ///
748    /// The encoder is per-frame, but the bytes are a stream, so the property
749    /// that matters is that concatenating encoded frames yields a block
750    /// sequence a decoder reads as the same events. An encoder that omitted the
751    /// dispatching blank line would pass every single-frame test above and
752    /// fail this one.
753    #[test]
754    fn concatenated_frames_read_back_as_the_same_events() -> Result<(), TestError> {
755        let frames = [
756            SseEvent::new(EventId::new(1), "one".to_owned()),
757            SseEvent::new(EventId::new(2), "two\nlines".to_owned()),
758            SseEvent::without_id("three".to_owned()),
759        ];
760        let stream: String = frames.iter().map(SseEvent::encode).collect();
761
762        let mut decoded = Vec::new();
763        for block in stream.trim_end_matches('\n').split("\n\n") {
764            if let Some(event) = SseEvent::dispatch(block)? {
765                decoded.push(event);
766            }
767        }
768        assert_eq!(decoded, frames.to_vec());
769        Ok(())
770    }
771
772    // -----------------------------------------------------------------------
773    // The `event` name
774    // -----------------------------------------------------------------------
775
776    /// A named frame survives the wire with its name attached.
777    ///
778    /// The claim the legacy transport rests on: a client that dispatches on
779    /// the name has to be able to *get* the name, and the only way to know
780    /// that is a round trip rather than a comparison against a hand-written
781    /// expectation.
782    #[test]
783    fn a_named_frame_round_trips_its_name() -> Result<(), TestError> {
784        let frame = SseEvent::named(names::ENDPOINT, "/messages?sessionId=abc".to_string());
785        let decoded = SseEvent::decode(&frame.encode())?;
786
787        if !decoded.is_named(&names::ENDPOINT) {
788            return Err("the name did not survive the round trip".into());
789        }
790        if decoded.data() != "/messages?sessionId=abc" {
791            return Err("the payload changed across the round trip".into());
792        }
793        // And it must not look like a `message`, or a client dispatching on
794        // the name would take the wrong branch.
795        if decoded.is_named(&names::MESSAGE) {
796            return Err("an endpoint frame was mistaken for a message".into());
797        }
798        Ok(())
799    }
800
801    /// An unnamed frame stays unnamed, which is what Streamable HTTP needs.
802    #[test]
803    fn an_unnamed_frame_stays_unnamed() -> Result<(), TestError> {
804        let frame = SseEvent::new(EventId::new(7), "{}".to_string());
805        let decoded = SseEvent::decode(&frame.encode())?;
806
807        if decoded.name().is_some() {
808            return Err("an unnamed frame came back named".into());
809        }
810        if decoded.is_named(&names::MESSAGE) {
811            return Err("an unnamed frame matched a name".into());
812        }
813        Ok(())
814    }
815
816    /// The two names and their payloads, encoded the way a legacy server
817    /// writes them, decode as the two things a legacy client must tell apart.
818    #[test]
819    fn the_two_legacy_frames_are_told_apart_by_name() -> Result<(), TestError> {
820        let wire = concat!(
821            "event: endpoint\ndata: /messages?sessionId=abc\n\n",
822            "event: message\ndata: {\"jsonrpc\":\"2.0\"}\n\n",
823        );
824        let mut frames = Vec::new();
825        for block in wire.split("\n\n").filter(|b| !b.trim().is_empty()) {
826            frames.push(SseEvent::decode(block)?);
827        }
828        // Borrowed rather than destructured by value: a slice cannot be moved
829        // out of, and the claim is the *shape* of the pair, which a by-ref
830        // pattern says as plainly as a by-value one.
831        let [endpoint, message] = frames.as_slice() else {
832            return Err(format!("expected two frames, found {}", frames.len()).into());
833        };
834
835        if !endpoint.is_named(&names::ENDPOINT) {
836            return Err("the first frame is not an endpoint frame".into());
837        }
838        if endpoint.data() != "/messages?sessionId=abc" {
839            return Err("the endpoint frame lost its URI".into());
840        }
841        if !message.is_named(&names::MESSAGE) {
842            return Err("the second frame is not a message frame".into());
843        }
844        Ok(())
845    }
846
847    /// A newline between `event:` and the next field is a **separator**, not
848    /// part of a name — and the decoder gets that right by never being able to
849    /// see the alternative.
850    ///
851    /// The first version of this test asserted that a name containing a
852    /// terminator is refused, and it was asserting something unreachable.
853    /// [`for_each_line`](super::for_each_line) has already split the block on
854    /// LF (and on CR) by the time a field value is parsed, so a value can
855    /// never *contain* a terminator by the time [`EventName::from_str`] sees
856    /// it. The `LineTerminator` arm of `EventNameError` is therefore
857    /// unreachable from the decoder and exists for the **encoder** side, where
858    /// a name this crate wrote is spliced into a line.
859    ///
860    /// What the decoder is obliged to do is read the two lines as what they
861    /// are — a name and a payload — and this asserts that. A decoder that
862    /// tried to "repair" the injection by refusing the block would be wrong:
863    /// this text is a perfectly legal frame.
864    #[test]
865    fn a_terminator_after_a_name_is_a_separator_and_not_part_of_it() -> Result<(), TestError> {
866        let block = "event: message\ndata: {\"jsonrpc\":\"2.0\"}\n\n";
867        let frame = SseEvent::decode(block)?;
868
869        if !frame.is_named(&names::MESSAGE) {
870            return Err("the name was not read as `message`".into());
871        }
872        if frame.data() != "{\"jsonrpc\":\"2.0\"}" {
873            return Err(format!("the payload was read as {:?}", frame.data()).into());
874        }
875        Ok(())
876    }
877
878    /// `event:` with no value means the default name, not a name of its own.
879    ///
880    /// Accepting it would make `Some("")` and `None` two spellings of one
881    /// dispatch, and a client comparing the name would have to handle both.
882    #[test]
883    fn an_empty_event_field_is_refused() -> Result<(), TestError> {
884        let block = "event:\ndata: x\n\n";
885        if SseEvent::decode(block) != Err(SseError::MalformedName(EventNameError::Empty)) {
886            return Err("an empty event field was not refused".into());
887        }
888        Ok(())
889    }
890
891    /// The encoder writes the name on its own line, before the data.
892    ///
893    /// Asserted on the bytes rather than through a decode, because a codec
894    /// that dropped the name and one that wrote it in the wrong place both
895    /// round-trip cleanly through *this* decoder — and a legacy peer does not
896    /// use this decoder.
897    #[test]
898    fn the_encoder_writes_the_name_on_its_own_line() -> Result<(), TestError> {
899        let encoded = SseEvent::named(names::MESSAGE, "{}".to_string()).encode();
900        if !encoded.starts_with("event: message\ndata: {}\n\n") {
901            return Err(format!("encoded as {encoded:?}").into());
902        }
903        Ok(())
904    }
905
906    /// A parsed name and a constant name are the same name.
907    ///
908    /// `Cow` makes the two representations different — one borrowed, one owned
909    /// — and a comparison that read the representation rather than the
910    /// characters would make every dispatch against a peer's name fail. This
911    /// is the test for that, through the frame, which is where it would bite.
912    #[test]
913    fn a_peer_supplied_name_matches_a_constant_one() -> Result<(), TestError> {
914        let parsed = EventName::from_str("message")?;
915        let frame = SseEvent::named(parsed, "{}".to_string());
916        if !frame.is_named(&names::MESSAGE) {
917            return Err("a parsed name did not match the constant".into());
918        }
919        Ok(())
920    }
921}