Skip to main content

net/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2//! A minimal blocking HTTP/1.1 client over any `Read + Write`. RFC 0006.
3//!
4//! This is the single highest-leverage minimalism decision (RFC 0002): one
5//! ~250-line module replaces the `ureq`/`url`→IDNA→ICU dependency tax. It
6//! carries the intelligence wire over TCP, TLS, unix sockets, and vsock
7//! alike — the transport is just the stream.
8//!
9//! Two request paths: [`send`] buffers the whole response (the LLM/intelligence
10//! path), and [`send_streaming`] returns the status + headers plus a live reader
11//! so the caller can either buffer it (`application/json`) or pump it as an **SSE**
12//! stream ([`SseReader`]) — the MCP Streamable HTTP transport, where a response may
13//! be a single JSON body or a `text/event-stream`, and a long-lived GET carries
14//! server→client notifications.
15//! `connect_tcp` is intentionally unguarded: agentd's HTTP client only ever
16//! dials the *operator-configured* intelligence endpoint, which the model
17//! cannot influence. The SSRF classifier (`net::ssrf`) exists for any future
18//! model/agent-supplied URL surface and is composed at the call site that
19//! needs it.
20
21use std::io::{self, BufRead, BufReader, Read, Write};
22use std::net::TcpStream;
23use std::time::Duration;
24
25/// Response body cap. LLM responses can be large; 8 MiB is generous without
26/// being an unbounded allocation from a hostile peer.
27pub const MAX_RESPONSE: usize = 8 * 1024 * 1024;
28
29/// Any bidirectional byte stream the HTTP client can run over. `Box<dyn Stream>`
30/// is itself `Read + Write` (via std's `impl<R: Read + ?Sized> Read for Box<R>`),
31/// so an OWNED boxed stream can be handed to [`send_streaming`] by value — used
32/// by the long-lived MCP notification SSE reader.
33pub trait Stream: Read + Write {}
34impl<T: Read + Write> Stream for T {}
35
36/// A parsed absolute URL (the subset we need: scheme/host/port/path).
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Url {
39    pub scheme: String,
40    pub host: String,
41    pub port: u16,
42    /// Path + query, always starting with `/`.
43    pub path: String,
44}
45
46impl Url {
47    /// Parse `http(s)://host[:port][/path][?query]`. No `url` crate — we only
48    /// support the absolute http/https forms agentd actually issues.
49    pub fn parse(s: &str) -> Result<Url, String> {
50        let (scheme, rest) = s
51            .split_once("://")
52            .ok_or_else(|| format!("not an absolute URL: {s}"))?;
53        let scheme = scheme.to_ascii_lowercase();
54        let default_port = match scheme.as_str() {
55            "http" => 80,
56            "https" => 443,
57            other => return Err(format!("unsupported scheme: {other}")),
58        };
59        let (authority, path) = match rest.find('/') {
60            Some(i) => (&rest[..i], &rest[i..]),
61            None => (rest, "/"),
62        };
63        if authority.is_empty() {
64            return Err(format!("missing host in URL: {s}"));
65        }
66        let (host, port) = match authority.rsplit_once(':') {
67            // ':' only counts as a port separator if what follows is numeric
68            // (guards against IPv6 literals, which we don't expect here).
69            Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() => (
70                h.to_string(),
71                p.parse().map_err(|_| format!("bad port in {s}"))?,
72            ),
73            _ => (authority.to_string(), default_port),
74        };
75        Ok(Url {
76            scheme,
77            host,
78            port,
79            path: path.to_string(),
80        })
81    }
82
83    pub fn is_tls(&self) -> bool {
84        self.scheme == "https"
85    }
86
87    /// The `Host:` header value (includes a non-default port).
88    pub fn host_header(&self) -> String {
89        let default = if self.is_tls() { 443 } else { 80 };
90        if self.port == default {
91            self.host.clone()
92        } else {
93            format!("{}:{}", self.host, self.port)
94        }
95    }
96}
97
98/// A parsed HTTP response.
99#[derive(Debug, Clone)]
100pub struct Response {
101    pub status: u16,
102    pub headers: Vec<(String, String)>,
103    pub body: Vec<u8>,
104}
105
106impl Response {
107    /// Case-insensitive header lookup (header names are stored lowercased).
108    pub fn header(&self, name: &str) -> Option<&str> {
109        let name = name.to_ascii_lowercase();
110        self.headers
111            .iter()
112            .find(|(k, _)| *k == name)
113            .map(|(_, v)| v.as_str())
114    }
115
116    pub fn is_success(&self) -> bool {
117        (200..300).contains(&self.status)
118    }
119
120    pub fn body_str(&self) -> std::borrow::Cow<'_, str> {
121        String::from_utf8_lossy(&self.body)
122    }
123}
124
125/// Whether `host` names the local loopback — the dev/test carve-out for
126/// plaintext `http://` (production transports are TLS-only). Accepts the IPv4
127/// loopback block (`127.0.0.0/8`), the IPv6 loopback (`::1`, bare or
128/// bracketed), and the literal name `localhost`. A resolvable-but-unresolved
129/// name is NOT loopback — this classifies the written form, without DNS.
130pub fn is_loopback_host(host: &str) -> bool {
131    let h = host.trim_start_matches('[').trim_end_matches(']');
132    if h.eq_ignore_ascii_case("localhost") {
133        return true;
134    }
135    h.parse::<std::net::IpAddr>()
136        .map(|ip| ip.is_loopback())
137        .unwrap_or(false)
138}
139
140/// Connect a plain TCP stream with connect + read/write timeouts. Intentionally
141/// unguarded — the only caller dials the operator-configured endpoint; the SSRF
142/// classifier (`net::ssrf`) is composed at any model/agent-supplied URL surface.
143pub fn connect_tcp(host: &str, port: u16, timeout: Duration) -> io::Result<TcpStream> {
144    use std::net::ToSocketAddrs;
145    let addr = (host, port).to_socket_addrs()?.next().ok_or_else(|| {
146        io::Error::new(
147            io::ErrorKind::NotFound,
148            format!("cannot resolve {host}:{port}"),
149        )
150    })?;
151    let stream = TcpStream::connect_timeout(&addr, timeout)?;
152    stream.set_read_timeout(Some(timeout))?;
153    stream.set_write_timeout(Some(timeout))?;
154    stream.set_nodelay(true).ok();
155    Ok(stream)
156}
157
158/// Issue one request over `stream` and read the full response. Adds `Host`,
159/// `Connection: close`, and `Content-Length`; the caller supplies any other
160/// headers (e.g. `Authorization`, `Content-Type`).
161pub fn send<S: Read + Write + ?Sized>(
162    stream: &mut S,
163    host_header: &str,
164    method: &str,
165    path: &str,
166    headers: &[(&str, &str)],
167    body: &[u8],
168) -> io::Result<Response> {
169    let mut req: Vec<u8> = Vec::with_capacity(256 + body.len());
170    write!(req, "{method} {path} HTTP/1.1\r\n")?;
171    write!(req, "Host: {host_header}\r\n")?;
172    req.extend_from_slice(b"Connection: close\r\n");
173    for (k, v) in headers {
174        // Reject CR/LF injection in caller-supplied headers (RFC 0012).
175        if k.contains(['\r', '\n']) || v.contains(['\r', '\n']) {
176            return Err(io::Error::new(
177                io::ErrorKind::InvalidInput,
178                "CR/LF in header",
179            ));
180        }
181        write!(req, "{k}: {v}\r\n")?;
182    }
183    write!(req, "Content-Length: {}\r\n\r\n", body.len())?;
184    req.extend_from_slice(body);
185    stream.write_all(&req)?;
186    stream.flush()?;
187
188    let mut reader = BufReader::new(stream);
189    read_response(&mut reader)
190}
191
192/// Read the status line + headers off a response, leaving the reader positioned
193/// at the start of the body. Header names are lowercased.
194fn read_head<R: BufRead>(r: &mut R) -> io::Result<(u16, Vec<(String, String)>)> {
195    let mut status_line = String::new();
196    r.read_line(&mut status_line)?;
197    let status = parse_status(&status_line)?;
198
199    let mut headers = Vec::new();
200    loop {
201        let mut line = String::new();
202        if r.read_line(&mut line)? == 0 {
203            break;
204        }
205        let line = line.trim_end_matches(['\r', '\n']);
206        if line.is_empty() {
207            break;
208        }
209        if let Some((k, v)) = line.split_once(':') {
210            headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
211        }
212    }
213    Ok((status, headers))
214}
215
216fn read_response<R: BufRead>(r: &mut R) -> io::Result<Response> {
217    let (status, headers) = read_head(r)?;
218
219    let content_length = headers
220        .iter()
221        .find(|(k, _)| k == "content-length")
222        .and_then(|(_, v)| v.parse::<usize>().ok());
223    let chunked = headers
224        .iter()
225        .any(|(k, v)| k == "transfer-encoding" && v.to_ascii_lowercase().contains("chunked"));
226
227    let body = if chunked {
228        read_chunked(r)?
229    } else if let Some(n) = content_length {
230        read_exact_capped(r, n)?
231    } else {
232        // Connection: close — read to EOF, capped.
233        read_to_end_capped(r)?
234    };
235
236    Ok(Response {
237        status,
238        headers,
239        body,
240    })
241}
242
243fn parse_status(line: &str) -> io::Result<u16> {
244    // "HTTP/1.1 200 OK"
245    line.split_whitespace()
246        .nth(1)
247        .and_then(|s| s.parse().ok())
248        .ok_or_else(|| {
249            io::Error::new(
250                io::ErrorKind::InvalidData,
251                format!("bad status line: {line:?}"),
252            )
253        })
254}
255
256fn read_exact_capped<R: Read>(r: &mut R, n: usize) -> io::Result<Vec<u8>> {
257    if n > MAX_RESPONSE {
258        return Err(io::Error::new(
259            io::ErrorKind::InvalidData,
260            "response exceeds cap",
261        ));
262    }
263    let mut buf = vec![0u8; n];
264    r.read_exact(&mut buf)?;
265    Ok(buf)
266}
267
268fn read_to_end_capped<R: Read>(r: &mut R) -> io::Result<Vec<u8>> {
269    let mut buf = Vec::new();
270    r.take(MAX_RESPONSE as u64 + 1).read_to_end(&mut buf)?;
271    if buf.len() > MAX_RESPONSE {
272        return Err(io::Error::new(
273            io::ErrorKind::InvalidData,
274            "response exceeds cap",
275        ));
276    }
277    Ok(buf)
278}
279
280fn read_chunked<R: BufRead>(r: &mut R) -> io::Result<Vec<u8>> {
281    let mut body = Vec::new();
282    loop {
283        let mut size_line = String::new();
284        r.read_line(&mut size_line)?;
285        let size_hex = size_line.trim_end_matches(['\r', '\n']);
286        // A chunk extension (`;name=val`) may follow the size.
287        let size_hex = size_hex.split(';').next().unwrap_or("").trim();
288        let size = usize::from_str_radix(size_hex, 16)
289            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad chunk size"))?;
290        if size == 0 {
291            // Consume the trailing CRLF (and any trailers) until blank line.
292            loop {
293                let mut t = String::new();
294                if r.read_line(&mut t)? == 0 || t.trim_end_matches(['\r', '\n']).is_empty() {
295                    break;
296                }
297            }
298            break;
299        }
300        if body.len() + size > MAX_RESPONSE {
301            return Err(io::Error::new(
302                io::ErrorKind::InvalidData,
303                "chunked response exceeds cap",
304            ));
305        }
306        let mut chunk = vec![0u8; size];
307        r.read_exact(&mut chunk)?;
308        body.extend_from_slice(&chunk);
309        // Trailing CRLF after the chunk.
310        let mut crlf = [0u8; 2];
311        r.read_exact(&mut crlf)?;
312    }
313    Ok(body)
314}
315
316/// A streamed response: status + headers, plus the reader positioned at the body.
317/// The caller decides how to drain it — [`into_body`] to buffer (`application/json`)
318/// or [`sse`] to pump it as an SSE event stream (`text/event-stream`). Owns the
319/// underlying stream, matching the MCP client's per-request connection model.
320pub struct StreamingResponse<S: Read + Write> {
321    pub status: u16,
322    pub headers: Vec<(String, String)>,
323    reader: BufReader<S>,
324}
325
326impl<S: Read + Write> StreamingResponse<S> {
327    pub fn header(&self, name: &str) -> Option<&str> {
328        let name = name.to_ascii_lowercase();
329        self.headers
330            .iter()
331            .find(|(k, _)| *k == name)
332            .map(|(_, v)| v.as_str())
333    }
334    pub fn is_success(&self) -> bool {
335        (200..300).contains(&self.status)
336    }
337    /// The lowercased `Content-Type` (media type only, params stripped).
338    pub fn content_type(&self) -> Option<&str> {
339        self.header("content-type")
340            .map(|v| v.split(';').next().unwrap_or(v).trim())
341    }
342    /// `true` when the body is `text/event-stream` (Streamable HTTP SSE).
343    pub fn is_event_stream(&self) -> bool {
344        self.content_type() == Some("text/event-stream")
345    }
346    /// Buffer the whole body (capped), honoring `Content-Length`/`chunked`/close.
347    pub fn into_body(mut self) -> io::Result<Vec<u8>> {
348        let content_length = self
349            .headers
350            .iter()
351            .find(|(k, _)| k == "content-length")
352            .and_then(|(_, v)| v.parse::<usize>().ok());
353        let chunked = self
354            .headers
355            .iter()
356            .any(|(k, v)| k == "transfer-encoding" && v.to_ascii_lowercase().contains("chunked"));
357        if chunked {
358            read_chunked(&mut self.reader)
359        } else if let Some(n) = content_length {
360            read_exact_capped(&mut self.reader, n)
361        } else {
362            read_to_end_capped(&mut self.reader)
363        }
364    }
365    /// Consume into an [`SseReader`] to pump `text/event-stream` events.
366    pub fn sse(self) -> SseReader<BufReader<S>> {
367        SseReader::new(self.reader)
368    }
369
370    /// Consume into the raw body reader — for a response that turned out NOT to
371    /// be an event stream (e.g. a peer that answered `application/json`).
372    pub fn into_reader(self) -> BufReader<S> {
373        self.reader
374    }
375}
376
377/// Issue one request over an OWNED `stream` and return the status + headers +
378/// body reader WITHOUT draining the body (unlike [`send`]). Adds `Host`,
379/// `Connection: close`, and `Content-Length`; the caller supplies the rest
380/// (`Accept`, `Authorization`, `Content-Type`, `Mcp-Session-Id`, …).
381pub fn send_streaming<S: Read + Write>(
382    mut stream: S,
383    host_header: &str,
384    method: &str,
385    path: &str,
386    headers: &[(&str, &str)],
387    body: &[u8],
388) -> io::Result<StreamingResponse<S>> {
389    let mut req: Vec<u8> = Vec::with_capacity(256 + body.len());
390    write!(req, "{method} {path} HTTP/1.1\r\n")?;
391    write!(req, "Host: {host_header}\r\n")?;
392    req.extend_from_slice(b"Connection: close\r\n");
393    for (k, v) in headers {
394        if k.contains(['\r', '\n']) || v.contains(['\r', '\n']) {
395            return Err(io::Error::new(
396                io::ErrorKind::InvalidInput,
397                "CR/LF in header",
398            ));
399        }
400        write!(req, "{k}: {v}\r\n")?;
401    }
402    write!(req, "Content-Length: {}\r\n\r\n", body.len())?;
403    req.extend_from_slice(body);
404    stream.write_all(&req)?;
405    stream.flush()?;
406
407    let mut reader = BufReader::new(stream);
408    let (status, headers) = read_head(&mut reader)?;
409    Ok(StreamingResponse {
410        status,
411        headers,
412        reader,
413    })
414}
415
416/// One parsed SSE event (RFC 6455-style `text/event-stream`). For MCP, `data` is
417/// a JSON-RPC message; `event`/`id` are the optional SSE field lines.
418#[derive(Debug, Clone, Default, PartialEq, Eq)]
419pub struct SseEvent {
420    pub event: Option<String>,
421    pub data: String,
422    pub id: Option<String>,
423}
424
425/// A blocking, line-based SSE reader. `next_event` accumulates `field: value`
426/// lines and emits one [`SseEvent`] per blank-line separator (multiple `data:`
427/// lines join with `\n`), returning `Ok(None)` at end of stream. Bounded per
428/// event by [`MAX_RESPONSE`] so a hostile stream cannot exhaust memory.
429pub struct SseReader<R: BufRead> {
430    r: R,
431}
432
433impl<R: BufRead> SseReader<R> {
434    pub fn new(r: R) -> SseReader<R> {
435        SseReader { r }
436    }
437
438    /// Read the next event, or `Ok(None)` at EOF. Comment lines (`:` prefix) and
439    /// unknown fields are ignored per the SSE spec.
440    pub fn next_event(&mut self) -> io::Result<Option<SseEvent>> {
441        let mut ev = SseEvent::default();
442        let mut saw_field = false;
443        let mut total = 0usize;
444        loop {
445            let mut line = String::new();
446            let n = self.r.read_line(&mut line)?;
447            if n == 0 {
448                // EOF: flush a pending event if one was in progress.
449                return Ok(if saw_field { Some(ev) } else { None });
450            }
451            total += n;
452            if total > MAX_RESPONSE {
453                return Err(io::Error::new(
454                    io::ErrorKind::InvalidData,
455                    "SSE event exceeds cap",
456                ));
457            }
458            let line = line.trim_end_matches(['\r', '\n']);
459            if line.is_empty() {
460                // Blank line dispatches the accumulated event.
461                if saw_field {
462                    return Ok(Some(ev));
463                }
464                continue; // stray blank line between events
465            }
466            if line.starts_with(':') {
467                continue; // comment
468            }
469            let (field, value) = match line.split_once(':') {
470                Some((f, v)) => (f, v.strip_prefix(' ').unwrap_or(v)),
471                None => (line, ""), // a bare field name with empty value
472            };
473            saw_field = true;
474            match field {
475                "event" => ev.event = Some(value.to_string()),
476                "id" => ev.id = Some(value.to_string()),
477                "data" => {
478                    if !ev.data.is_empty() {
479                        ev.data.push('\n');
480                    }
481                    ev.data.push_str(value);
482                }
483                _ => {} // retry/unknown — ignore
484            }
485        }
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use std::io::Cursor;
493
494    #[test]
495    fn url_parse_https_default_port() {
496        let u = Url::parse("https://api.openai.com/v1/chat/completions").unwrap();
497        assert_eq!(u.scheme, "https");
498        assert_eq!(u.host, "api.openai.com");
499        assert_eq!(u.port, 443);
500        assert_eq!(u.path, "/v1/chat/completions");
501        assert_eq!(u.host_header(), "api.openai.com");
502        assert!(u.is_tls());
503    }
504
505    #[test]
506    fn url_parse_http_with_port_and_no_path() {
507        let u = Url::parse("http://localhost:8080").unwrap();
508        assert_eq!(u.port, 8080);
509        assert_eq!(u.path, "/");
510        assert_eq!(u.host_header(), "localhost:8080");
511        assert!(!u.is_tls());
512    }
513
514    #[test]
515    fn url_rejects_bad_scheme() {
516        assert!(Url::parse("ftp://x/").is_err());
517        assert!(Url::parse("no-scheme").is_err());
518    }
519
520    #[test]
521    fn response_content_length() {
522        let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"ok\":true}!!";
523        let mut cur = Cursor::new(raw.as_bytes().to_vec());
524        let resp = read_response(&mut cur).unwrap();
525        assert_eq!(resp.status, 200);
526        assert_eq!(resp.header("content-type"), Some("application/json"));
527        assert_eq!(resp.body, b"{\"ok\":true}!!");
528        assert!(resp.is_success());
529    }
530
531    #[test]
532    fn response_chunked() {
533        let raw = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
534        let mut cur = Cursor::new(raw.as_bytes().to_vec());
535        let resp = read_response(&mut cur).unwrap();
536        assert_eq!(resp.body, b"hello world");
537    }
538
539    #[test]
540    fn cr_lf_header_injection_rejected() {
541        let mut sink: Vec<u8> = Vec::new();
542        // a write-only fake stream: Cursor over Vec implements Write+Read
543        let mut stream = Cursor::new(Vec::new());
544        let _ = &mut sink;
545        let err = send(&mut stream, "h", "POST", "/", &[("X", "a\r\nEvil: 1")], b"").unwrap_err();
546        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
547    }
548
549    /// A fake duplex stream: reads return a canned server response, writes are
550    /// captured (so a request/response round-trip is testable without sockets).
551    struct FakeStream {
552        resp: Cursor<Vec<u8>>,
553        sink: Vec<u8>,
554    }
555    impl Read for FakeStream {
556        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
557            self.resp.read(buf)
558        }
559    }
560    impl Write for FakeStream {
561        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
562            self.sink.extend_from_slice(buf);
563            Ok(buf.len())
564        }
565        fn flush(&mut self) -> io::Result<()> {
566            Ok(())
567        }
568    }
569
570    fn sse_events(body: &str) -> Vec<SseEvent> {
571        let mut r = SseReader::new(BufReader::new(Cursor::new(body.as_bytes().to_vec())));
572        let mut out = Vec::new();
573        while let Some(e) = r.next_event().unwrap() {
574            out.push(e);
575        }
576        out
577    }
578
579    #[test]
580    fn sse_parses_events_with_event_id_and_data() {
581        let body = "event: message\nid: 7\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n";
582        let evs = sse_events(body);
583        assert_eq!(evs.len(), 1);
584        assert_eq!(evs[0].event.as_deref(), Some("message"));
585        assert_eq!(evs[0].id.as_deref(), Some("7"));
586        assert_eq!(evs[0].data, "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}");
587    }
588
589    #[test]
590    fn sse_joins_multi_data_lines_and_ignores_comments() {
591        // Comment line, then an event whose data spans two `data:` lines.
592        let body = ": keep-alive\ndata: line1\ndata: line2\n\ndata: second\n\n";
593        let evs = sse_events(body);
594        assert_eq!(evs.len(), 2);
595        assert_eq!(evs[0].data, "line1\nline2");
596        assert_eq!(evs[1].data, "second");
597    }
598
599    #[test]
600    fn sse_flushes_trailing_event_without_final_blank_line() {
601        let evs = sse_events("data: only\n");
602        assert_eq!(evs.len(), 1);
603        assert_eq!(evs[0].data, "only");
604    }
605
606    #[test]
607    fn send_streaming_reads_head_then_buffers_json_body() {
608        let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nMcp-Session-Id: abc123\r\nContent-Length: 11\r\n\r\n{\"ok\":true}".to_string();
609        let stream = FakeStream {
610            resp: Cursor::new(raw.into_bytes()),
611            sink: Vec::new(),
612        };
613        let resp = send_streaming(
614            stream,
615            "h",
616            "POST",
617            "/mcp",
618            &[("Accept", "application/json")],
619            b"{}",
620        )
621        .unwrap();
622        assert_eq!(resp.status, 200);
623        assert!(resp.is_success());
624        assert_eq!(resp.content_type(), Some("application/json"));
625        assert!(!resp.is_event_stream());
626        assert_eq!(resp.header("mcp-session-id"), Some("abc123"));
627        assert_eq!(resp.into_body().unwrap(), b"{\"ok\":true}");
628    }
629
630    #[test]
631    fn send_streaming_detects_event_stream_and_pumps_sse() {
632        let raw = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"x\":1}}\n\n".to_string();
633        let stream = FakeStream {
634            resp: Cursor::new(raw.into_bytes()),
635            sink: Vec::new(),
636        };
637        let resp = send_streaming(stream, "h", "POST", "/mcp", &[], b"{}").unwrap();
638        assert!(resp.is_event_stream());
639        let mut sse = resp.sse();
640        let ev = sse.next_event().unwrap().expect("one event");
641        assert!(ev.data.contains("\"id\":1"));
642        assert!(sse.next_event().unwrap().is_none());
643    }
644}