Skip to main content

mcp/
http_server.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **Streamable HTTP** MCP server: HTTP/1.1 + SSE over TCP (plain, or TLS via
3//! the [`net::tls`] acceptor), reusing the same [`Handler`] / [`lifecycle_response`]
4//! / [`SubRegistry`](crate::server::SubRegistry) as the socket servers. This is the
5//! serving mirror of the crate's HTTP *client* ([`crate::http`]) and the transport
6//! the HTTPS control plane rides.
7//!
8//! Model (Streamable HTTP, both eras):
9//!   * **Unary** — one `POST` carrying a JSON-RPC request; the reply is
10//!     `application/json`. `initialize` is stamped with an `Mcp-Session-Id`
11//!     (legacy). One request per connection (`Connection: close`), matching the
12//!     client's dialer.
13//!   * **Reactive** — a `POST subscriptions/listen` (modern, stateless): the
14//!     connection becomes a long-lived `text/event-stream`. Each requested uri is
15//!     run through the handler's normal `resources/subscribe` gate (so the
16//!     embedder's per-origin subscribability rules apply unchanged) and, if
17//!     accepted, this connection's SSE write half is registered in the shared
18//!     registry — so the embedder's existing `notify_*` pushes reach it as SSE
19//!     `data:` events. The stream is held open with periodic keep-alive comments;
20//!     a failed write prunes the subscriptions and ends the connection.
21//!
22//! **Trust is never transport-derived.** Every request is classified by an
23//! [`HttpAuth`] the embedder supplies (mutual-TLS client identity primary, bearer
24//! token alternative); an unauthenticated peer gets `401` and never reaches the
25//! handler.
26
27use crate::rpc::{Incoming, Request};
28use crate::server::{Handler, PeerOrigin, ServeStream, SharedWriter, SubRegistry};
29use crate::wire::method;
30use serde_json::{Value, json};
31use std::io::{self, BufRead, BufReader, Read, Write};
32use std::net::{TcpListener, TcpStream};
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::{Arc, Mutex};
35use std::thread;
36use std::time::Duration;
37
38/// How often an idle SSE stream writes a keep-alive comment (also the disconnect
39/// probe — a failed write ends the stream and prunes its subscriptions).
40const SSE_KEEPALIVE: Duration = Duration::from_secs(15);
41
42/// Cap on a request body (JSON-RPC frames are small; this bounds a hostile peer).
43const MAX_BODY: usize = 8 * 1024 * 1024;
44
45/// Cap on the whole request HEAD — request line plus every header line. A body
46/// is bounded by its declared `Content-Length`; a head is bounded by nothing the
47/// peer tells us, so it has to be bounded by us: without this, one connection
48/// that never sends a newline grows a `String` until the process dies, and it
49/// can do that BEFORE authenticating (the head is read to find the credential).
50const MAX_HEAD_BYTES: usize = 64 * 1024;
51
52/// Cap on the number of header lines kept. The byte cap already bounds the
53/// total, but 64 KiB of four-byte headers is still ~13k `Vec` entries and every
54/// `RequestParts::header` lookup is a linear scan over them — so bound the count
55/// as well. Real callers send a handful.
56const MAX_HEADERS: usize = 100;
57
58/// A verified mTLS peer's identity, surfaced so an embedder can match a caller
59/// to a named principal rather than merely observing "a cert was presented".
60/// All-empty for a plain / no-client-cert connection. rustls has already verified
61/// the chain; these fields are only *read* from the leaf certificate, so they are
62/// safe to compare against — but only because verification already happened.
63#[derive(Default, Clone)]
64pub struct PeerId {
65    /// A verified client certificate was presented (mutual TLS).
66    pub cert: bool,
67    /// The leaf certificate's subject CN, if any.
68    pub subject: Option<String>,
69    /// The leaf certificate's SANs (DNS / URI / IP); a SPIFFE X.509-SVID's
70    /// `spiffe://…` arrives here as a URI SAN.
71    pub sans: Vec<String>,
72}
73
74/// The parts of an inbound request an [`HttpAuth`] classifies trust from.
75pub struct RequestParts<'a> {
76    /// The request's headers (lowercased names), e.g. to read `authorization`.
77    pub headers: &'a [(String, String)],
78    /// Whether the peer presented a verified client certificate (mutual TLS).
79    pub peer_cert: bool,
80    /// The verified mTLS leaf subject CN, if any.
81    pub peer_subject: Option<&'a str>,
82    /// The verified mTLS leaf SANs (DNS / URI / IP); empty without a client cert.
83    pub peer_sans: &'a [String],
84}
85
86impl RequestParts<'_> {
87    /// The value of header `name` (compare lowercased), if present.
88    pub fn header(&self, name: &str) -> Option<&str> {
89        self.headers
90            .iter()
91            .find(|(k, _)| k == name)
92            .map(|(_, v)| v.as_str())
93    }
94}
95
96/// The embedder's auth policy: classify an inbound request's trust origin, or
97/// reject it. Called once per connection before the handler sees anything. The
98/// framework NEVER trusts by transport alone — return `None` to answer `401`.
99pub trait HttpAuth: Send + Sync + 'static {
100    fn authenticate(&self, parts: &RequestParts) -> Option<PeerOrigin>;
101}
102
103/// Allow every request as [`PeerOrigin::Management`] — for loopback dev / tests
104/// only. NOT for a real listener (it makes the transport the trust boundary,
105/// exactly the posture the pivot removes).
106pub struct AllowAll;
107impl HttpAuth for AllowAll {
108    fn authenticate(&self, _parts: &RequestParts) -> Option<PeerOrigin> {
109        Some(PeerOrigin::Management)
110    }
111}
112
113/// Per-listener serving options beyond the handler/auth pair.
114#[derive(Default, Clone)]
115pub struct ServeOptions {
116    /// Extra allowed browser `Origin` values (`scheme://host[:port]`, exact
117    /// match) beyond the always-allowed loopback origins. A request from an
118    /// allowed origin is served **with CORS response headers** (and its
119    /// `OPTIONS` preflight answered), so a browser client on that origin can
120    /// actually read the reply; any other cross-site origin stays 403
121    /// (DNS-rebinding defense).
122    pub extra_origins: Vec<String>,
123}
124
125/// How accepted TCP connections are wrapped: plaintext (loopback dev) or TLS
126/// (the production control plane). The TLS variant carries the [`net::tls`]
127/// acceptor, which drives the handshake (and, under mTLS, verifies the client
128/// certificate) at accept time.
129pub enum HttpAcceptor {
130    /// Plaintext HTTP — loopback dev / tests only.
131    Plain,
132    /// HTTPS via a configured TLS acceptor (optionally mutual-TLS).
133    #[cfg(feature = "tls")]
134    Tls(net::tls::TlsAcceptor),
135}
136
137/// Bind a TCP listener for HTTP serving. Kept separate from the accept loop so
138/// the caller can log/act on a successful bind (or propagate the error) before
139/// the accept thread starts.
140pub fn bind_tcp(addr: &str) -> io::Result<TcpListener> {
141    TcpListener::bind(addr)
142}
143
144/// Spawn the background accept thread: one blocking thread per connection, each
145/// serving HTTP/1.1 (+ SSE) against `handler`, with trust classified by `auth`.
146/// Peers that authenticate arrive in whatever [`PeerOrigin`] `auth` mints.
147#[allow(clippy::too_many_arguments)]
148pub fn spawn_accept_http(
149    listener: TcpListener,
150    acceptor: Arc<HttpAcceptor>,
151    handler: Arc<dyn Handler>,
152    auth: Arc<dyn HttpAuth>,
153    subs: SubRegistry,
154    conn_counter: Arc<AtomicU64>,
155    write_timeout: Duration,
156) -> io::Result<()> {
157    spawn_accept_http_opts(
158        listener,
159        acceptor,
160        handler,
161        auth,
162        subs,
163        conn_counter,
164        write_timeout,
165        ServeOptions::default(),
166    )
167}
168
169/// [`spawn_accept_http`] with explicit [`ServeOptions`] (extra browser origins).
170#[allow(clippy::too_many_arguments)]
171pub fn spawn_accept_http_opts(
172    listener: TcpListener,
173    acceptor: Arc<HttpAcceptor>,
174    handler: Arc<dyn Handler>,
175    auth: Arc<dyn HttpAuth>,
176    subs: SubRegistry,
177    conn_counter: Arc<AtomicU64>,
178    write_timeout: Duration,
179    opts: ServeOptions,
180) -> io::Result<()> {
181    let opts = Arc::new(opts);
182    thread::Builder::new()
183        .name("serve-http".into())
184        .spawn(move || {
185            for tcp in listener.incoming().flatten() {
186                let acceptor = Arc::clone(&acceptor);
187                let handler = Arc::clone(&handler);
188                let auth = Arc::clone(&auth);
189                let subs = Arc::clone(&subs);
190                let conn_counter = Arc::clone(&conn_counter);
191                let opts = Arc::clone(&opts);
192                thread::Builder::new()
193                    .name("serve-http-conn".into())
194                    .spawn(move || {
195                        accept_and_serve(
196                            tcp,
197                            &acceptor,
198                            &handler,
199                            &auth,
200                            &subs,
201                            &conn_counter,
202                            write_timeout,
203                            &opts,
204                        );
205                    })
206                    .ok();
207            }
208        })
209        .map(|_| ())
210}
211
212#[allow(clippy::too_many_arguments)]
213fn accept_and_serve(
214    tcp: TcpStream,
215    acceptor: &HttpAcceptor,
216    handler: &Arc<dyn Handler>,
217    auth: &Arc<dyn HttpAuth>,
218    subs: &SubRegistry,
219    conn_counter: &AtomicU64,
220    write_timeout: Duration,
221    opts: &ServeOptions,
222) {
223    let _ = tcp.set_write_timeout(Some(write_timeout));
224    let _ = tcp.set_read_timeout(Some(write_timeout));
225    match acceptor {
226        HttpAcceptor::Plain => {
227            serve_conn(
228                tcp,
229                PeerId::default(),
230                handler,
231                auth,
232                subs,
233                conn_counter,
234                opts,
235            );
236        }
237        // A failed TLS/mTLS handshake never reaches the protocol layer.
238        #[cfg(feature = "tls")]
239        HttpAcceptor::Tls(tls) => {
240            if let Ok(stream) = tls.accept(tcp) {
241                let peer = peer_id(&stream);
242                serve_conn(stream, peer, handler, auth, subs, conn_counter, opts);
243            }
244        }
245    }
246}
247
248/// Lift the verified mTLS peer's identity (subject CN + SANs) so the embedder's
249/// auth policy can match it to a principal. `default()` when no client cert was
250/// presented — an absent identity must read as "unidentified", never as a match.
251#[cfg(feature = "tls")]
252fn peer_id(stream: &net::tls::ServerTlsStream) -> PeerId {
253    match net::tls::peer_identity(stream) {
254        Some(id) => PeerId {
255            cert: true,
256            subject: id.subject_cn,
257            sans: id.sans,
258        },
259        None => PeerId::default(),
260    }
261}
262
263/// Serve one accepted (already TLS-terminated) connection. Generic over the
264/// concrete stream so plain TCP and the TLS stream share one code path.
265fn serve_conn<S: Read + Write + Send + 'static>(
266    stream: S,
267    peer: PeerId,
268    handler: &Arc<dyn Handler>,
269    auth: &Arc<dyn HttpAuth>,
270    subs: &SubRegistry,
271    conn_counter: &AtomicU64,
272    opts: &ServeOptions,
273) {
274    let mut reader = BufReader::new(stream);
275    let req = match read_request(&mut reader) {
276        Ok(req) => req,
277        // A refused head is answered, not just dropped: 431 is a real answer a
278        // client can act on, and it costs nothing — the peer never got past the
279        // reader, so no handler and no auth decision was involved.
280        Err(ReadError::HeadTooLarge) => {
281            let _ = write_simple(
282                reader.get_mut(),
283                431,
284                "Request Header Fields Too Large",
285                b"request head exceeds the header size/count limits",
286                None,
287            );
288            return;
289        }
290        Err(ReadError::Incomplete) => return, // malformed / EOF before a full request
291    };
292
293    // DNS-rebinding defense, which Streamable HTTP requires: a browser
294    // always sends `Origin`, so a page tricked into POSTing to a local agentd
295    // carries its own site there. Reject any request whose `Origin` is present and
296    // NOT a loopback origin (or a configured `ServeOptions::extra_origins` entry) —
297    // a non-browser control-plane / mesh caller sends no `Origin` and is
298    // unaffected. This is a transport-level guard, applied BEFORE auth (a rebind
299    // presents no credential either, but defense-in-depth covers the loopback
300    // `AllowAll` dev path where auth alone would let it through). An ALLOWED
301    // browser origin is echoed back as CORS headers so the page can read replies.
302    let cors = match check_origin(&req.headers, &opts.extra_origins) {
303        OriginCheck::NoBrowser => None,
304        OriginCheck::Allowed(o) => Some(o),
305        OriginCheck::Denied => {
306            let _ = write_simple(
307                reader.get_mut(),
308                403,
309                "Forbidden",
310                b"cross-origin request rejected",
311                None,
312            );
313            return;
314        }
315    };
316    // A CORS preflight (`OPTIONS`) carries no credential and never reaches the
317    // handler — answer it before auth so a browser on an allowed origin can
318    // proceed to the real POST.
319    if req.method.eq_ignore_ascii_case("OPTIONS") {
320        let _ = write_preflight(reader.get_mut(), cors.as_deref());
321        return;
322    }
323
324    // Trust classification — the transport is never the boundary.
325    let origin = {
326        let parts = RequestParts {
327            headers: &req.headers,
328            peer_cert: peer.cert,
329            peer_subject: peer.subject.as_deref(),
330            peer_sans: &peer.sans,
331        };
332        auth.authenticate(&parts)
333    };
334    let Some(origin) = origin else {
335        let _ = write_simple(reader.get_mut(), 401, "Unauthorized", b"", cors.as_deref());
336        return;
337    };
338
339    // Only POST carries JSON-RPC; a GET (the legacy notification stream) is not
340    // served — our clients negotiate the modern `subscriptions/listen` path.
341    if !req.method.eq_ignore_ascii_case("POST") {
342        let _ = write_simple(
343            reader.get_mut(),
344            405,
345            "Method Not Allowed",
346            b"POST a JSON-RPC request, or POST subscriptions/listen for the SSE stream",
347            cors.as_deref(),
348        );
349        return;
350    }
351
352    let conn = conn_counter.fetch_add(1, Ordering::Relaxed);
353    handler.on_connect(origin, conn);
354
355    let incoming: Result<Incoming, _> = serde_json::from_slice(&req.body);
356    match incoming {
357        Ok(Incoming::Request(rpc_req)) if rpc_req.method == method::SUBSCRIPTIONS_LISTEN => {
358            serve_listen(
359                reader,
360                rpc_req,
361                origin,
362                conn,
363                handler,
364                subs,
365                cors.as_deref(),
366            );
367        }
368        // A server-streaming method (the embedder declares them — e.g. the A2A
369        // streaming pair): the response is an SSE stream of JSON-RPC frames.
370        Ok(Incoming::Request(rpc_req)) if handler.streams(&rpc_req.method) => {
371            serve_stream(reader, rpc_req, origin, conn, handler, cors.as_deref());
372            remove_and_disconnect(subs, conn, origin, handler);
373        }
374        Ok(Incoming::Request(rpc_req)) => {
375            serve_unary(
376                reader.get_mut(),
377                rpc_req,
378                origin,
379                conn,
380                handler,
381                cors.as_deref(),
382            );
383            remove_and_disconnect(subs, conn, origin, handler);
384        }
385        // A notification POST (e.g. notifications/initialized) → 202, no body.
386        Ok(Incoming::Notification(_)) | Ok(Incoming::Response(_)) => {
387            let _ = write_simple(reader.get_mut(), 202, "Accepted", b"", cors.as_deref());
388            remove_and_disconnect(subs, conn, origin, handler);
389        }
390        Err(_) => {
391            let _ = write_simple(
392                reader.get_mut(),
393                400,
394                "Bad Request",
395                b"invalid JSON-RPC frame",
396                cors.as_deref(),
397            );
398            remove_and_disconnect(subs, conn, origin, handler);
399        }
400    }
401}
402
403/// A server-streaming request → a `text/event-stream` of JSON-RPC frames: the
404/// dispatch's INTERMEDIATE frames flow through the shared SSE writer as `data:`
405/// events while it runs, keep-alive comments cover the quiet stretches (the
406/// dispatch may block for minutes between frames), and the RETURNED `Response`
407/// is written as the FINAL event before the connection closes.
408fn serve_stream<S: Read + Write + Send + 'static>(
409    reader: BufReader<S>,
410    req: Request,
411    origin: PeerOrigin,
412    conn: u64,
413    handler: &Arc<dyn Handler>,
414    cors: Option<&str>,
415) {
416    let mut stream = reader.into_inner();
417    let head = format!(
418        "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n{}Connection: close\r\n\r\n",
419        cors_headers(cors)
420    );
421    if stream
422        .write_all(head.as_bytes())
423        .and_then(|_| stream.flush())
424        .is_err()
425    {
426        return;
427    }
428    let writer: SharedWriter = Arc::new(Mutex::new(ServeStream::Http(Box::new(stream))));
429
430    // Keep-alives while the dispatch blocks between frames — the same probe
431    // cadence the listen stream uses. The mutex serializes them against frames.
432    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
433    let ka = {
434        let writer = Arc::clone(&writer);
435        let stop = Arc::clone(&stop);
436        thread::spawn(move || {
437            while !stop.load(Ordering::Relaxed) {
438                thread::sleep(SSE_KEEPALIVE);
439                if stop.load(Ordering::Relaxed) {
440                    break;
441                }
442                let alive = writer
443                    .lock()
444                    .map(|mut w| {
445                        w.write_all(b": keep-alive\n\n")
446                            .and_then(|_| w.flush())
447                            .is_ok()
448                    })
449                    .unwrap_or(false);
450                if !alive {
451                    break;
452                }
453            }
454        })
455    };
456
457    let resp = handler.dispatch(req, origin, &writer, conn);
458    stop.store(true, Ordering::Relaxed);
459    if let Ok(mut w) = writer.lock() {
460        let _ = w.write_response(&resp);
461    }
462    let _ = ka.join();
463}
464
465/// A unary request → `application/json` reply. Streaming responses (a2a) are a
466/// later phase; the crate's dispatch returns one `Response` here.
467fn serve_unary<S: Write>(
468    stream: &mut S,
469    req: Request,
470    origin: PeerOrigin,
471    conn: u64,
472    handler: &Arc<dyn Handler>,
473    cors: Option<&str>,
474) {
475    // A null sink for the dispatch's `writer` arg: unary methods don't push, and
476    // a stray write must never corrupt the HTTP response.
477    let sink: SharedWriter = Arc::new(Mutex::new(ServeStream::Http(Box::new(io::sink()))));
478    let is_initialize = req.method == method::INITIALIZE;
479    let resp = handler.dispatch(req, origin, &sink, conn);
480    let body = serde_json::to_vec(&resp).unwrap_or_default();
481    let session = if is_initialize {
482        format!("Mcp-Session-Id: {}\r\n", next_session_id())
483    } else {
484        String::new()
485    };
486    let head = format!(
487        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{session}{}Content-Length: {}\r\nConnection: close\r\n\r\n",
488        cors_headers(cors),
489        body.len()
490    );
491    let _ = stream.write_all(head.as_bytes());
492    let _ = stream.write_all(&body);
493    let _ = stream.flush();
494}
495
496/// A `subscriptions/listen` → the connection becomes a long-lived SSE stream.
497/// Each requested uri is gated through the handler's normal `resources/subscribe`
498/// path (so the embedder's per-origin rules apply); accepted ones register this
499/// connection's SSE writer in the shared registry. The stream is then held open
500/// with keep-alive comments until the peer disconnects.
501fn serve_listen<S: Read + Write + Send + 'static>(
502    reader: BufReader<S>,
503    req: Request,
504    origin: PeerOrigin,
505    conn: u64,
506    handler: &Arc<dyn Handler>,
507    subs: &SubRegistry,
508    cors: Option<&str>,
509) {
510    let uris = listen_uris(&req);
511    let mut stream = reader.into_inner();
512    // SSE response head.
513    let head = format!(
514        "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n{}Connection: close\r\n\r\n",
515        cors_headers(cors)
516    );
517    if stream
518        .write_all(head.as_bytes())
519        .and_then(|_| stream.flush())
520        .is_err()
521    {
522        remove_and_disconnect(subs, conn, origin, handler);
523        return;
524    }
525
526    // The connection's write half becomes the shared SSE sink. Registration goes
527    // through the handler's own subscribe gate (a synthetic resources/subscribe
528    // per uri), so this reuses the embedder's subscribability rules verbatim.
529    let writer: SharedWriter = Arc::new(Mutex::new(ServeStream::Http(Box::new(stream))));
530    for uri in &uris {
531        let sub_req = Request::new(0, method::RESOURCES_SUBSCRIBE, Some(json!({ "uri": uri })));
532        let _ = handler.dispatch(sub_req, origin, &writer, conn);
533    }
534
535    // Hold the stream open, using keep-alive comments as the disconnect probe.
536    loop {
537        thread::sleep(SSE_KEEPALIVE);
538        let alive = writer
539            .lock()
540            .map(|mut w| {
541                w.write_all(b": keep-alive\n\n")
542                    .and_then(|_| w.flush())
543                    .is_ok()
544            })
545            .unwrap_or(false);
546        if !alive {
547            break;
548        }
549    }
550    remove_and_disconnect(subs, conn, origin, handler);
551}
552
553/// The `resourceSubscriptions` uri list from a `subscriptions/listen` request
554/// (`params.notifications.resourceSubscriptions`).
555fn listen_uris(req: &Request) -> Vec<String> {
556    req.params
557        .as_ref()
558        .and_then(|p| p.get("notifications"))
559        .and_then(|n| n.get("resourceSubscriptions"))
560        .and_then(Value::as_array)
561        .map(|a| {
562            a.iter()
563                .filter_map(Value::as_str)
564                .map(str::to_string)
565                .collect()
566        })
567        .unwrap_or_default()
568}
569
570fn remove_and_disconnect(
571    subs: &SubRegistry,
572    conn: u64,
573    origin: PeerOrigin,
574    handler: &Arc<dyn Handler>,
575) {
576    crate::server::remove_conn_subscriptions(subs, conn);
577    handler.on_disconnect(origin, conn);
578}
579
580/// A minimal status-only HTTP response (no JSON-RPC body).
581fn write_simple<S: Write>(
582    stream: &mut S,
583    code: u16,
584    reason: &str,
585    body: &[u8],
586    cors: Option<&str>,
587) -> io::Result<()> {
588    let head = format!(
589        "HTTP/1.1 {code} {reason}\r\nContent-Type: text/plain\r\n{}Content-Length: {}\r\nConnection: close\r\n\r\n",
590        cors_headers(cors),
591        body.len()
592    );
593    stream.write_all(head.as_bytes())?;
594    stream.write_all(body)?;
595    stream.flush()
596}
597
598/// The CORS response headers for an allowed browser origin (empty otherwise).
599/// The origin is echoed (never `*`) so a credentialed fetch works, and
600/// `Mcp-Session-Id` is exposed for the initialize handshake.
601fn cors_headers(origin: Option<&str>) -> String {
602    match origin {
603        Some(o) => format!(
604            "Access-Control-Allow-Origin: {o}\r\nVary: Origin\r\nAccess-Control-Expose-Headers: Mcp-Session-Id\r\n"
605        ),
606        None => String::new(),
607    }
608}
609
610/// Answer a CORS preflight (`OPTIONS`). An allowed origin gets the grant; a
611/// non-browser `OPTIONS` (no `Origin`) gets a plain 204.
612fn write_preflight<S: Write>(stream: &mut S, origin: Option<&str>) -> io::Result<()> {
613    let grant = match origin {
614        Some(o) => format!(
615            "Access-Control-Allow-Origin: {o}\r\nVary: Origin\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: content-type, authorization, last-event-id, mcp-session-id\r\nAccess-Control-Max-Age: 600\r\n"
616        ),
617        None => String::new(),
618    };
619    let head =
620        format!("HTTP/1.1 204 No Content\r\n{grant}Content-Length: 0\r\nConnection: close\r\n\r\n");
621    stream.write_all(head.as_bytes())?;
622    stream.flush()
623}
624
625// ---- raw-HTTP surface (non-JSON-RPC embedders, e.g. the webhook listener) ----
626
627/// A raw inbound HTTP request handed straight to a [`RawHandler`]: method, target
628/// (path + optional query), lowercased headers, and the raw body. Unlike the
629/// [`Handler`] path this does no JSON-RPC parsing and no transport-level auth —
630/// the embedder routes by [`RawRequest::path`] and authenticates itself (e.g. a
631/// per-webhook HMAC over the raw body). The DNS-rebind `Origin` guard and TLS
632/// termination still apply.
633pub struct RawRequest {
634    pub method: String,
635    pub target: String,
636    pub headers: Vec<(String, String)>,
637    pub body: Vec<u8>,
638    /// Whether the peer presented a verified client certificate (mutual TLS).
639    pub peer_cert: bool,
640    /// The verified mTLS leaf subject CN, if any.
641    pub peer_subject: Option<String>,
642    /// The verified mTLS leaf SANs (DNS / URI / IP); empty without a client cert.
643    pub peer_sans: Vec<String>,
644}
645
646impl RawRequest {
647    /// Header `name` (compare lowercased), if present.
648    pub fn header(&self, name: &str) -> Option<&str> {
649        self.headers
650            .iter()
651            .find(|(k, _)| k == name)
652            .map(|(_, v)| v.as_str())
653    }
654    /// The path portion of the target (any `?query` dropped).
655    pub fn path(&self) -> &str {
656        self.target.split('?').next().unwrap_or(&self.target)
657    }
658}
659
660/// A raw HTTP response a [`RawHandler`] returns.
661pub struct RawResponse {
662    pub status: u16,
663    pub reason: &'static str,
664    pub content_type: &'static str,
665    pub body: Vec<u8>,
666    /// Extra response headers (e.g. `Retry-After` on a 429). Names as written.
667    pub headers: Vec<(&'static str, String)>,
668}
669
670impl RawResponse {
671    /// A JSON response.
672    pub fn json(status: u16, reason: &'static str, body: impl Into<Vec<u8>>) -> RawResponse {
673        RawResponse {
674            status,
675            reason,
676            content_type: "application/json",
677            body: body.into(),
678            headers: Vec::new(),
679        }
680    }
681    /// A short text response.
682    pub fn text(status: u16, reason: &'static str, body: impl Into<Vec<u8>>) -> RawResponse {
683        RawResponse {
684            status,
685            reason,
686            content_type: "text/plain",
687            body: body.into(),
688            headers: Vec::new(),
689        }
690    }
691}
692
693/// A raw-HTTP embedder surface (the agentd webhook listener). One call per
694/// request; the embedder routes and authenticates itself.
695pub trait RawHandler: Send + Sync + 'static {
696    fn handle(&self, req: &RawRequest) -> RawResponse;
697}
698
699/// Spawn a raw-HTTP accept loop — TLS-terminated like [`spawn_accept_http`], with
700/// the same DNS-rebind `Origin` guard — dispatching each request to `handler`.
701pub fn spawn_accept_raw(
702    listener: TcpListener,
703    acceptor: Arc<HttpAcceptor>,
704    handler: Arc<dyn RawHandler>,
705    write_timeout: Duration,
706) -> io::Result<()> {
707    thread::Builder::new()
708        .name("serve-webhook".into())
709        .spawn(move || {
710            for tcp in listener.incoming().flatten() {
711                let acceptor = Arc::clone(&acceptor);
712                let handler = Arc::clone(&handler);
713                thread::Builder::new()
714                    .name("webhook-conn".into())
715                    .spawn(move || {
716                        let _ = tcp.set_write_timeout(Some(write_timeout));
717                        let _ = tcp.set_read_timeout(Some(write_timeout));
718                        match &*acceptor {
719                            HttpAcceptor::Plain => serve_conn_raw(tcp, PeerId::default(), &handler),
720                            #[cfg(feature = "tls")]
721                            HttpAcceptor::Tls(tls) => {
722                                if let Ok(stream) = tls.accept(tcp) {
723                                    let peer = peer_id(&stream);
724                                    serve_conn_raw(stream, peer, &handler);
725                                }
726                            }
727                        }
728                    })
729                    .ok();
730            }
731        })
732        .map(|_| ())
733}
734
735fn serve_conn_raw<S: Read + Write + Send + 'static>(
736    stream: S,
737    peer: PeerId,
738    handler: &Arc<dyn RawHandler>,
739) {
740    let mut reader = BufReader::new(stream);
741    let req = match read_request(&mut reader) {
742        Ok(req) => req,
743        Err(ReadError::HeadTooLarge) => {
744            let _ = write_simple(
745                reader.get_mut(),
746                431,
747                "Request Header Fields Too Large",
748                b"request head exceeds the header size/count limits",
749                None,
750            );
751            return;
752        }
753        Err(ReadError::Incomplete) => return,
754    };
755    // Webhook callers are servers, not browsers — loopback-only origins here.
756    if matches!(check_origin(&req.headers, &[]), OriginCheck::Denied) {
757        let _ = write_simple(
758            reader.get_mut(),
759            403,
760            "Forbidden",
761            b"cross-origin request rejected",
762            None,
763        );
764        return;
765    }
766    let raw = RawRequest {
767        method: req.method,
768        target: req.target,
769        headers: req.headers,
770        body: req.body,
771        peer_cert: peer.cert,
772        peer_subject: peer.subject,
773        peer_sans: peer.sans,
774    };
775    let resp = handler.handle(&raw);
776    let _ = write_raw(reader.get_mut(), &resp);
777}
778
779fn write_raw<S: Write>(stream: &mut S, resp: &RawResponse) -> io::Result<()> {
780    let mut head = format!(
781        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
782        resp.status,
783        resp.reason,
784        resp.content_type,
785        resp.body.len()
786    );
787    for (name, value) in &resp.headers {
788        head.push_str(name);
789        head.push_str(": ");
790        head.push_str(value);
791        head.push_str("\r\n");
792    }
793    head.push_str("\r\n");
794    stream.write_all(head.as_bytes())?;
795    stream.write_all(&resp.body)?;
796    stream.flush()
797}
798
799/// A parsed HTTP request: method, target, headers (lowercased names), body.
800struct HttpRequest {
801    method: String,
802    #[allow(dead_code)]
803    target: String,
804    headers: Vec<(String, String)>,
805    body: Vec<u8>,
806}
807
808/// The DNS-rebinding gate's verdict on a request's `Origin` header.
809enum OriginCheck {
810    /// No `Origin` header — a non-browser caller; no CORS needed.
811    NoBrowser,
812    /// An acceptable browser origin (loopback, or configured) — echo it as CORS.
813    Allowed(String),
814    /// A cross-site browser origin — reject 403.
815    Denied,
816}
817
818/// Classify a request's `Origin` (if any) — the DNS-rebinding gate. No `Origin`
819/// header → a non-browser caller, allowed with no CORS. Present → it must name a
820/// loopback origin or an exact `extra` entry (a configured web-UI origin).
821fn check_origin(headers: &[(String, String)], extra: &[String]) -> OriginCheck {
822    match headers.iter().find(|(k, _)| k == "origin") {
823        None => OriginCheck::NoBrowser,
824        Some((_, origin)) => {
825            if origin_is_loopback(origin) || extra.iter().any(|e| e == origin) {
826                OriginCheck::Allowed(origin.clone())
827            } else {
828                OriginCheck::Denied
829            }
830        }
831    }
832}
833
834/// Whether an `Origin` value (`scheme://host[:port]`) names a loopback host. The
835/// opaque `"null"` origin (sandboxed iframe / `file://`) is treated as untrusted.
836fn origin_is_loopback(origin: &str) -> bool {
837    let after_scheme = origin.split_once("://").map(|(_, r)| r).unwrap_or(origin);
838    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
839    // Strip the optional port, keeping a bracketed IPv6 literal intact.
840    let host = if let Some(v6) = authority.strip_prefix('[') {
841        v6.split(']').next().unwrap_or(v6)
842    } else {
843        authority.split(':').next().unwrap_or(authority)
844    };
845    host == "localhost" || host == "::1" || host.starts_with("127.")
846}
847
848/// A process-unique `Mcp-Session-Id` for `initialize`. It is a correlation
849/// HANDLE, not a credential (auth is mTLS/bearer, orthogonal), and each
850/// connection is a single `Connection: close` request — so uniqueness, not
851/// unguessability, is what matters. Time-millis + a monotone counter guarantees
852/// uniqueness with no `rand` dependency (the minimalism moat).
853fn next_session_id() -> String {
854    static SEQ: AtomicU64 = AtomicU64::new(0);
855    let n = SEQ.fetch_add(1, Ordering::Relaxed);
856    let millis = std::time::SystemTime::now()
857        .duration_since(std::time::UNIX_EPOCH)
858        .map(|d| d.as_millis())
859        .unwrap_or(0);
860    format!("s-{millis:x}-{n:x}")
861}
862
863/// Why a request could not be read.
864enum ReadError {
865    /// EOF before a complete request, a malformed head, or an over-long body —
866    /// nothing worth answering; the connection is dropped.
867    Incomplete,
868    /// The head blew [`MAX_HEAD_BYTES`] / [`MAX_HEADERS`]. Answered `431` so the
869    /// peer learns why, rather than being cut off mid-sentence.
870    HeadTooLarge,
871}
872
873/// Read one head line (request line or header), spending from `budget`. The
874/// budget bounds the READ itself rather than being checked after the fact: a
875/// line is refused before it is buffered, which is the whole point — a peer that
876/// never sends a newline must not be able to make us allocate for it. `Ok(0)`
877/// is EOF.
878fn read_head_line<S: Read>(
879    reader: &mut BufReader<S>,
880    budget: &mut usize,
881    line: &mut String,
882) -> Result<usize, ReadError> {
883    // `budget + 1`: a line that exactly fills the budget still terminates inside
884    // it, and one byte more is what proves the cap was blown.
885    let n = Read::take(&mut *reader, *budget as u64 + 1)
886        .read_line(line)
887        .map_err(|_| ReadError::Incomplete)?;
888    if n > *budget {
889        return Err(ReadError::HeadTooLarge);
890    }
891    *budget -= n;
892    Ok(n)
893}
894
895/// Read one HTTP/1.1 request (request line, headers, `Content-Length` body)
896/// under the head bounds above.
897fn read_request<S: Read>(reader: &mut BufReader<S>) -> Result<HttpRequest, ReadError> {
898    let mut budget = MAX_HEAD_BYTES;
899    let mut request_line = String::new();
900    if read_head_line(reader, &mut budget, &mut request_line)? == 0 {
901        return Err(ReadError::Incomplete);
902    }
903    let mut parts = request_line.split_whitespace();
904    let method = parts.next().ok_or(ReadError::Incomplete)?.to_string();
905    let target = parts.next().ok_or(ReadError::Incomplete)?.to_string();
906
907    let mut headers = Vec::new();
908    let mut content_length = 0usize;
909    loop {
910        let mut line = String::new();
911        if read_head_line(reader, &mut budget, &mut line)? == 0 {
912            break;
913        }
914        let line = line.trim_end();
915        if line.is_empty() {
916            break; // end of headers
917        }
918        if let Some((k, v)) = line.split_once(':') {
919            if headers.len() >= MAX_HEADERS {
920                return Err(ReadError::HeadTooLarge);
921            }
922            let name = k.trim().to_ascii_lowercase();
923            let value = v.trim().to_string();
924            if name == "content-length" {
925                content_length = value.parse().unwrap_or(0);
926            }
927            headers.push((name, value));
928        }
929    }
930    if content_length > MAX_BODY {
931        return Err(ReadError::Incomplete);
932    }
933    let mut body = vec![0u8; content_length];
934    if content_length > 0 {
935        reader
936            .read_exact(&mut body)
937            .map_err(|_| ReadError::Incomplete)?;
938    }
939    Ok(HttpRequest {
940        method,
941        target,
942        headers,
943        body,
944    })
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::rpc::{self, Response};
951    use crate::server::{notify_resource_updated_keep, register_subscriber};
952    use std::io::BufRead;
953
954    /// A handler that advertises one subscribable resource and answers a tool
955    /// call — enough to exercise unary + reactive over HTTP. Holds the SAME
956    /// registry the server pushes through (the subscribe gate registers into it).
957    struct TestHandler {
958        subs: SubRegistry,
959    }
960    impl Handler for TestHandler {
961        fn dispatch(
962            &self,
963            req: Request,
964            _origin: PeerOrigin,
965            writer: &SharedWriter,
966            conn: u64,
967        ) -> Response {
968            if let Some(resp) = crate::server::lifecycle_response(
969                &req,
970                &json!({"name": "test", "version": "1"}),
971                &json!({"tools": {}, "resources": {"subscribe": true}}),
972            ) {
973                return resp;
974            }
975            match req.method.as_str() {
976                "tools/call" => Response::ok(req.id, json!({"ok": true})),
977                "resources/subscribe" => {
978                    let uri = req
979                        .params
980                        .as_ref()
981                        .and_then(|p| p["uri"].as_str())
982                        .unwrap_or("");
983                    // The gate: only `res://ok` is subscribable here.
984                    if uri == "res://ok" {
985                        register_subscriber(&self.subs, uri, conn, writer);
986                        Response::ok(req.id, json!({}))
987                    } else {
988                        Response::err(req.id, rpc::RESOURCE_NOT_FOUND, "no")
989                    }
990                }
991                _ => Response::err(req.id, rpc::METHOD_NOT_FOUND, "unknown"),
992            }
993        }
994    }
995
996    fn http_post(addr: &str, body: &str) -> (Vec<(String, String)>, String) {
997        let mut s = TcpStream::connect(addr).unwrap();
998        let req = format!(
999            "POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1000            body.len()
1001        );
1002        s.write_all(req.as_bytes()).unwrap();
1003        s.set_read_timeout(Some(Duration::from_secs(5))).ok();
1004        let mut reader = BufReader::new(s);
1005        let mut status = String::new();
1006        reader.read_line(&mut status).unwrap();
1007        let mut headers = Vec::new();
1008        loop {
1009            let mut l = String::new();
1010            reader.read_line(&mut l).unwrap();
1011            if l.trim().is_empty() {
1012                break;
1013            }
1014            if let Some((k, v)) = l.split_once(':') {
1015                headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
1016            }
1017        }
1018        let mut body = String::new();
1019        reader.read_to_string(&mut body).unwrap();
1020        (headers, body)
1021    }
1022
1023    fn spawn_server() -> (String, SubRegistry) {
1024        let subs: SubRegistry = Arc::new(Mutex::new(std::collections::HashMap::new()));
1025        let listener = bind_tcp("127.0.0.1:0").unwrap();
1026        let addr = listener.local_addr().unwrap().to_string();
1027        spawn_accept_http(
1028            listener,
1029            Arc::new(HttpAcceptor::Plain),
1030            Arc::new(TestHandler {
1031                subs: Arc::clone(&subs),
1032            }),
1033            Arc::new(AllowAll),
1034            Arc::clone(&subs),
1035            Arc::new(AtomicU64::new(0)),
1036            Duration::from_secs(5),
1037        )
1038        .unwrap();
1039        (addr, subs)
1040    }
1041
1042    #[test]
1043    fn unary_post_returns_application_json() {
1044        let (addr, _subs) = spawn_server();
1045        let (headers, body) = http_post(
1046            &addr,
1047            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x"}}"#,
1048        );
1049        assert!(
1050            headers
1051                .iter()
1052                .any(|(k, v)| k == "content-type" && v.contains("application/json")),
1053            "headers: {headers:?}"
1054        );
1055        let v: Value = serde_json::from_str(&body).unwrap();
1056        assert_eq!(v["result"]["ok"], true);
1057    }
1058
1059    /// POST with an explicit `Origin` header; returns the HTTP status code.
1060    fn http_post_origin(addr: &str, origin: Option<&str>, body: &str) -> u16 {
1061        let mut s = TcpStream::connect(addr).unwrap();
1062        let origin_line = origin
1063            .map(|o| format!("Origin: {o}\r\n"))
1064            .unwrap_or_default();
1065        let req = format!(
1066            "POST /mcp HTTP/1.1\r\nHost: x\r\n{origin_line}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1067            body.len()
1068        );
1069        s.write_all(req.as_bytes()).unwrap();
1070        s.set_read_timeout(Some(Duration::from_secs(5))).ok();
1071        let mut status = String::new();
1072        BufReader::new(s).read_line(&mut status).unwrap();
1073        status
1074            .split_whitespace()
1075            .nth(1)
1076            .and_then(|c| c.parse().ok())
1077            .unwrap_or(0)
1078    }
1079
1080    #[test]
1081    fn initialize_stamps_a_unique_session_header() {
1082        let (addr, _subs) = spawn_server();
1083        let init = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}"#;
1084        let sid = |addr: &str| {
1085            let (headers, _) = http_post(addr, init);
1086            headers
1087                .into_iter()
1088                .find(|(k, _)| k == "mcp-session-id")
1089                .map(|(_, v)| v)
1090        };
1091        let a = sid(&addr).expect("initialize stamps a session id");
1092        let b = sid(&addr).expect("second initialize stamps a session id");
1093        assert_ne!(a, "srv", "the session id is not the old constant");
1094        assert_ne!(a, b, "each initialize mints a distinct session id");
1095    }
1096
1097    #[test]
1098    fn a_cross_origin_request_is_rejected_403() {
1099        let (addr, _subs) = spawn_server();
1100        let call = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x"}}"#;
1101        // A browser cross-site Origin → 403 (DNS-rebinding defense).
1102        assert_eq!(
1103            http_post_origin(&addr, Some("https://evil.example"), call),
1104            403
1105        );
1106        // No Origin (the normal non-browser caller) → served (200).
1107        assert_eq!(http_post_origin(&addr, None, call), 200);
1108        // A loopback Origin (a local dev tool) → served.
1109        assert_eq!(
1110            http_post_origin(&addr, Some("http://localhost:3000"), call),
1111            200
1112        );
1113        assert_eq!(http_post_origin(&addr, Some("http://127.0.0.1"), call), 200);
1114    }
1115
1116    /// A server with an extra allowed origin configured.
1117    fn spawn_server_with_origins(extra: &[&str]) -> String {
1118        let subs: SubRegistry = Arc::new(Mutex::new(std::collections::HashMap::new()));
1119        let listener = bind_tcp("127.0.0.1:0").unwrap();
1120        let addr = listener.local_addr().unwrap().to_string();
1121        spawn_accept_http_opts(
1122            listener,
1123            Arc::new(HttpAcceptor::Plain),
1124            Arc::new(TestHandler {
1125                subs: Arc::clone(&subs),
1126            }),
1127            Arc::new(AllowAll),
1128            Arc::clone(&subs),
1129            Arc::new(AtomicU64::new(0)),
1130            Duration::from_secs(5),
1131            ServeOptions {
1132                extra_origins: extra.iter().map(|s| s.to_string()).collect(),
1133            },
1134        )
1135        .unwrap();
1136        addr
1137    }
1138
1139    /// A raw request; returns (status code, lowercased headers).
1140    fn http_raw(addr: &str, req: &str) -> (u16, Vec<(String, String)>) {
1141        let mut s = TcpStream::connect(addr).unwrap();
1142        s.write_all(req.as_bytes()).unwrap();
1143        s.set_read_timeout(Some(Duration::from_secs(5))).ok();
1144        let mut reader = BufReader::new(s);
1145        let mut status = String::new();
1146        reader.read_line(&mut status).unwrap();
1147        let code = status
1148            .split_whitespace()
1149            .nth(1)
1150            .and_then(|c| c.parse().ok())
1151            .unwrap_or(0);
1152        let mut headers = Vec::new();
1153        loop {
1154            let mut l = String::new();
1155            if reader.read_line(&mut l).unwrap_or(0) == 0 {
1156                break;
1157            }
1158            if l.trim().is_empty() {
1159                break;
1160            }
1161            if let Some((k, v)) = l.split_once(':') {
1162                headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
1163            }
1164        }
1165        (code, headers)
1166    }
1167
1168    #[test]
1169    fn a_configured_extra_origin_is_served_with_cors_headers() {
1170        let addr = spawn_server_with_origins(&["https://ui.example"]);
1171        let call = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x"}}"#;
1172        // The configured origin is allowed AND gets the CORS grant echoed.
1173        let req = format!(
1174            "POST / HTTP/1.1\r\nHost: x\r\nOrigin: https://ui.example\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{call}",
1175            call.len()
1176        );
1177        let (code, headers) = http_raw(&addr, &req);
1178        assert_eq!(code, 200);
1179        assert!(
1180            headers
1181                .iter()
1182                .any(|(k, v)| { k == "access-control-allow-origin" && v == "https://ui.example" }),
1183            "CORS echo missing: {headers:?}"
1184        );
1185        // A different cross-site origin stays rejected.
1186        let bad = format!(
1187            "POST / HTTP/1.1\r\nHost: x\r\nOrigin: https://evil.example\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{call}",
1188            call.len()
1189        );
1190        assert_eq!(http_raw(&addr, &bad).0, 403);
1191        // A loopback origin also gets the CORS echo (a local web UI on another port).
1192        let local = format!(
1193            "POST / HTTP/1.1\r\nHost: x\r\nOrigin: http://127.0.0.1:5173\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{call}",
1194            call.len()
1195        );
1196        let (code, headers) = http_raw(&addr, &local);
1197        assert_eq!(code, 200);
1198        assert!(
1199            headers
1200                .iter()
1201                .any(|(k, v)| k == "access-control-allow-origin" && v == "http://127.0.0.1:5173")
1202        );
1203    }
1204
1205    #[test]
1206    fn a_cors_preflight_options_is_answered_before_auth() {
1207        let addr = spawn_server_with_origins(&["https://ui.example"]);
1208        let req = "OPTIONS / HTTP/1.1\r\nHost: x\r\nOrigin: https://ui.example\r\nAccess-Control-Request-Method: POST\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
1209        let (code, headers) = http_raw(&addr, req);
1210        assert_eq!(code, 204);
1211        assert!(
1212            headers
1213                .iter()
1214                .any(|(k, v)| k == "access-control-allow-origin" && v == "https://ui.example")
1215        );
1216        assert!(
1217            headers
1218                .iter()
1219                .any(|(k, v)| k == "access-control-allow-headers" && v.contains("authorization"))
1220        );
1221        // A preflight from a denied origin is 403 (the rebind gate holds).
1222        let bad = "OPTIONS / HTTP/1.1\r\nHost: x\r\nOrigin: https://evil.example\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
1223        assert_eq!(http_raw(&addr, bad).0, 403);
1224    }
1225
1226    #[test]
1227    fn origin_loopback_classification() {
1228        assert!(origin_is_loopback("http://localhost"));
1229        assert!(origin_is_loopback("http://localhost:8080"));
1230        assert!(origin_is_loopback("https://127.0.0.1:443"));
1231        assert!(origin_is_loopback("http://[::1]:9000"));
1232        assert!(!origin_is_loopback("https://evil.example"));
1233        assert!(!origin_is_loopback("http://169.254.1.1")); // link-local, not loopback
1234        assert!(!origin_is_loopback("null")); // opaque origin → untrusted
1235    }
1236
1237    #[test]
1238    fn subscriptions_listen_streams_a_pushed_update_as_sse() {
1239        let (addr, subs) = spawn_server();
1240        // Open the SSE stream in a thread; it stays open, so read incrementally.
1241        let addr2 = addr.clone();
1242        let got = Arc::new(Mutex::new(String::new()));
1243        let got2 = Arc::clone(&got);
1244        thread::spawn(move || {
1245            let mut s = TcpStream::connect(&addr2).unwrap();
1246            let body = r#"{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen","params":{"notifications":{"resourceSubscriptions":["res://ok"]}}}"#;
1247            let req = format!(
1248                "POST /mcp HTTP/1.1\r\nHost: x\r\nAccept: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1249                body.len()
1250            );
1251            s.write_all(req.as_bytes()).unwrap();
1252            s.set_read_timeout(Some(Duration::from_secs(5))).ok();
1253            let mut reader = BufReader::new(s);
1254            let mut line = String::new();
1255            // Read until we see a data: line or time out.
1256            for _ in 0..50 {
1257                line.clear();
1258                if reader.read_line(&mut line).unwrap_or(0) == 0 {
1259                    break;
1260                }
1261                if line.starts_with("data:") {
1262                    *got2.lock().unwrap() = line.clone();
1263                    break;
1264                }
1265            }
1266        });
1267
1268        // Wait for the subscription to register, then push an update.
1269        let deadline = std::time::Instant::now() + Duration::from_secs(3);
1270        while std::time::Instant::now() < deadline {
1271            if subs.lock().unwrap().contains_key("res://ok") {
1272                break;
1273            }
1274            thread::sleep(Duration::from_millis(20));
1275        }
1276        notify_resource_updated_keep(&subs, "res://ok");
1277
1278        let deadline = std::time::Instant::now() + Duration::from_secs(3);
1279        loop {
1280            if got.lock().unwrap().starts_with("data:") {
1281                break;
1282            }
1283            assert!(std::time::Instant::now() < deadline, "no SSE push observed");
1284            thread::sleep(Duration::from_millis(20));
1285        }
1286        let data = got.lock().unwrap().clone();
1287        assert!(data.contains("notifications/resources/updated"), "{data}");
1288        assert!(data.contains("res://ok"), "{data}");
1289    }
1290}