Skip to main content

mcp/
http_server.rs

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