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}