Skip to main content

mcp/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The MCP **Streamable HTTP** client transport (v2.0.0). RFC 0004 §transport.
3//!
4//! A conformant remote MCP server is reached by `POST`ing a JSON-RPC message to a
5//! single endpoint; the server replies with either a `application/json` body (one
6//! message) or a `text/event-stream` (SSE) carrying one or more messages. A
7//! server-assigned `Mcp-Session-Id` (returned on `initialize`) is echoed on every
8//! subsequent request. Server→client notifications ride an optional long-lived
9//! `GET` SSE stream.
10//!
11//! The transport is stream-agnostic (it reuses the hand-rolled [`net::http`]
12//! client): `https://` runs over TCP+TLS (optionally mutual TLS), `http://` over
13//! plain TCP (a local sidecar), `unix:` over a unix socket, and `vsock:` over
14//! AF_VSOCK — none of which spawns a process (RFC 0012: no local exec surface).
15
16use net::http::{self, SseEvent, Url};
17#[cfg(feature = "tls")]
18use net::tls::ClientIdentity;
19use serde_json::Value;
20use std::io;
21use std::sync::Mutex;
22use std::time::Duration;
23
24/// A resolved MCP endpoint: where to connect + the HTTP `path`/`Host` to send.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum McpEndpoint {
27    /// `https://host[:port]/path` (TCP + TLS) or `http://…` (plain TCP).
28    Tcp {
29        host: String,
30        port: u16,
31        tls: bool,
32        path: String,
33        host_header: String,
34    },
35    /// `unix:/socket/path` — HTTP over a unix socket to a local sidecar.
36    Unix { socket: String, path: String },
37    /// `vsock:cid:port` — HTTP over AF_VSOCK to an enclave/microVM peer.
38    Vsock { cid: u32, port: u32, path: String },
39}
40
41impl McpEndpoint {
42    /// Parse a `--mcp name=<url>` endpoint. Accepts `https://`, `http://`,
43    /// `unix:/path`, and `vsock:cid:port`. For `unix:`/`vsock:` the HTTP request
44    /// path defaults to `/` (the sidecar routes); use `https://` for a specific
45    /// server path (e.g. `/mcp`).
46    pub fn parse(s: &str) -> Result<McpEndpoint, String> {
47        if let Some(sock) = s.strip_prefix("unix:") {
48            if sock.is_empty() {
49                return Err(format!("empty unix socket path: {s}"));
50            }
51            return Ok(McpEndpoint::Unix {
52                socket: sock.to_string(),
53                path: "/".to_string(),
54            });
55        }
56        if let Some(rest) = s.strip_prefix("vsock:") {
57            let (cid, port) = rest
58                .split_once(':')
59                .and_then(|(c, p)| Some((c.trim().parse().ok()?, p.trim().parse().ok()?)))
60                .ok_or_else(|| format!("bad vsock endpoint (want vsock:cid:port): {s}"))?;
61            return Ok(McpEndpoint::Vsock {
62                cid,
63                port,
64                path: "/".to_string(),
65            });
66        }
67        // http(s)
68        let url = Url::parse(s)?;
69        Ok(McpEndpoint::Tcp {
70            tls: url.is_tls(),
71            host_header: url.host_header(),
72            host: url.host,
73            port: url.port,
74            path: url.path,
75        })
76    }
77
78    /// The transport scheme name for the manifest/logs (never the address/creds).
79    pub fn scheme(&self) -> &'static str {
80        match self {
81            McpEndpoint::Tcp { tls: true, .. } => "https",
82            McpEndpoint::Tcp { tls: false, .. } => "http",
83            McpEndpoint::Unix { .. } => "unix",
84            McpEndpoint::Vsock { .. } => "vsock",
85        }
86    }
87
88    fn http_path(&self) -> &str {
89        match self {
90            McpEndpoint::Tcp { path, .. }
91            | McpEndpoint::Unix { path, .. }
92            | McpEndpoint::Vsock { path, .. } => path,
93        }
94    }
95
96    fn host_header(&self) -> &str {
97        match self {
98            McpEndpoint::Tcp { host_header, .. } => host_header,
99            McpEndpoint::Unix { .. } | McpEndpoint::Vsock { .. } => "localhost",
100        }
101    }
102}
103
104/// An MCP transport error (connect / HTTP / protocol).
105#[derive(Debug)]
106pub enum HttpError {
107    Connect(io::Error),
108    Http(io::Error),
109    /// A non-2xx HTTP status, with the (capped) response body — carried so the
110    /// caller can classify a modern JSON-RPC error (era detection, `-32022`
111    /// version retry) from the body rather than just the status code.
112    Status(u16, Vec<u8>),
113    /// The build lacks the feature this endpoint needs (e.g. `vsock`).
114    Unsupported(String),
115    /// No JSON-RPC response matched the request id before the stream ended.
116    NoResponse,
117}
118
119impl std::fmt::Display for HttpError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            HttpError::Connect(e) => write!(f, "mcp-http: connect: {e}"),
123            HttpError::Http(e) => write!(f, "mcp-http: {e}"),
124            HttpError::Status(s, _) => write!(f, "mcp-http: server returned HTTP {s}"),
125            HttpError::Unsupported(m) => write!(f, "mcp-http: {m}"),
126            HttpError::NoResponse => write!(f, "mcp-http: no JSON-RPC response before stream end"),
127        }
128    }
129}
130impl std::error::Error for HttpError {}
131
132/// The `Host` authority (host[:port]) of an MCP endpoint URL — the `@authority`
133/// AAuth signs over. `localhost` for non-TCP endpoints. Best-effort (a parse
134/// failure yields an empty string).
135pub fn authority_of(endpoint: &str) -> String {
136    McpEndpoint::parse(endpoint)
137        .map(|e| e.host_header().to_string())
138        .unwrap_or_default()
139}
140
141/// The classification of one [`HttpTransport::send_once`] attempt (AAuth loop).
142enum SendOutcome {
143    /// A final JSON-RPC result (or `None` for a notification ack).
144    Result(Option<Value>),
145    /// A terminal transport/HTTP error.
146    Error(HttpError),
147    /// The signer satisfied an `AAuth-Requirement`; re-sign and retry.
148    RetryAuth,
149}
150
151/// The AAuth-relevant fields of a server response (RFC 0023 §5 — the request
152/// loop). Handed to [`RequestSigner::on_response`] so the signer can satisfy a
153/// runtime `AAuth-Requirement` and decide whether a retry would now succeed.
154#[derive(Debug, Clone, Default)]
155pub struct AuthResponse {
156    pub status: u16,
157    /// The `AAuth-Requirement` header value (e.g. `agent-token`,
158    /// `auth-token; resource-token="…"`, `interaction; url=…; code=…`).
159    pub requirement: Option<String>,
160    /// An opaque `AAuth-Access` token the server issued (Case B).
161    pub access: Option<String>,
162    /// A `Location` for a pending interaction (202) to poll.
163    pub location: Option<String>,
164    /// A `Signature-Error` / `AAuth-Error` detail (diagnostics only).
165    pub error: Option<String>,
166}
167
168/// A per-request signer (RFC 0023 — AAuth). The transport calls [`sign`] just
169/// before each POST (the returned `(name, value)` pairs become request headers —
170/// the RFC 9421 `Signature-Input`/`Signature`/`Signature-Key`), and
171/// [`on_response`] after, to react to the server's `AAuth-Requirement` (adopt an
172/// access token, run the Person-Server flow) and re-sign+retry. Kept
173/// dependency-free here — the CRYPTO lives in the caller (agentd's `aauth`
174/// module); this crate only owns the seam, so `agentd-mcp` gains no crypto dep.
175pub trait RequestSigner: Send + Sync {
176    /// Sign one request. `authority` is the `Host` value (host[:port]); `path`
177    /// is the request-target. `body` is the JSON-RPC bytes (for a
178    /// `content-digest` cover when the server requires it). Returns headers to
179    /// add; an empty vec = send unsigned (let the server answer with its
180    /// requirement).
181    fn sign(&self, method: &str, authority: &str, path: &str, body: &[u8])
182    -> Vec<(String, String)>;
183    /// React to a response (RFC 0023 §5): adopt an `AAuth-Access` token, satisfy
184    /// an `AAuth-Requirement` (e.g. run the Person-Server exchange), and return
185    /// `true` iff the request should be RE-SIGNED and retried (a requirement was
186    /// newly satisfied). The default reacts to nothing. May do network I/O
187    /// (the PS token exchange).
188    fn on_response(&self, _resp: &AuthResponse, _authority: &str) -> bool {
189        false
190    }
191    /// An optional `AAuth-Capabilities` header value (interaction shapes the
192    /// agent can drive). `None` = omit.
193    fn capabilities(&self) -> Option<String> {
194        None
195    }
196    /// Whether this server requires a `content-digest` cover (learned at
197    /// discovery). The transport adds/covers the body digest when true.
198    fn wants_content_digest(&self, _authority: &str) -> bool {
199        false
200    }
201}
202
203/// The Streamable HTTP transport for one MCP server. Cheap to hold; each request
204/// opens a fresh connection (`Connection: close`), so there is no persistent
205/// socket to reap. `session` is set from the server's `Mcp-Session-Id` on the
206/// first response and echoed thereafter.
207pub struct HttpTransport {
208    endpoint: McpEndpoint,
209    /// Caller-owned auth + framing headers (e.g. `Authorization`, `x-api-key`).
210    /// Values may be secrets — never logged; this transport only writes them onto
211    /// the wire (RFC 0012 §3.7).
212    headers: Vec<(String, String)>,
213    /// A client identity for mutual TLS (TCP+TLS endpoints only).
214    #[cfg(feature = "tls")]
215    identity: Option<ClientIdentity>,
216    session: Mutex<Option<String>>,
217    /// The protocol version negotiated at `initialize`, echoed on every later
218    /// request as `MCP-Protocol-Version` (RFC transports §protocol-version-header
219    /// — a MUST for Streamable HTTP). `None` until the client sets it, so the
220    /// `initialize` request itself carries no header (no version agreed yet).
221    protocol_version: Mutex<Option<String>>,
222    /// An optional per-request signer (AAuth, RFC 0023). `None` = the endpoint
223    /// is called unsigned (the default; static-bearer/mTLS auth is unaffected).
224    signer: Option<std::sync::Arc<dyn RequestSigner>>,
225}
226
227impl HttpTransport {
228    pub fn new(endpoint: McpEndpoint, headers: Vec<(String, String)>) -> Self {
229        HttpTransport {
230            endpoint,
231            headers,
232            #[cfg(feature = "tls")]
233            identity: None,
234            session: Mutex::new(None),
235            protocol_version: Mutex::new(None),
236            signer: None,
237        }
238    }
239
240    /// Install a per-request signer (AAuth). Builder-style; call before use.
241    pub fn with_signer(mut self, signer: Option<std::sync::Arc<dyn RequestSigner>>) -> Self {
242        self.signer = signer;
243        self
244    }
245
246    /// Attach a mutual-TLS client identity (used only for `https://` endpoints).
247    #[cfg(feature = "tls")]
248    pub fn set_identity(&mut self, identity: Option<ClientIdentity>) {
249        self.identity = identity;
250    }
251
252    /// Record the negotiated protocol version, sent as `MCP-Protocol-Version` on
253    /// every subsequent request (called by the client after `initialize`/discovery).
254    pub fn set_protocol_version(&self, version: String) {
255        *self
256            .protocol_version
257            .lock()
258            .unwrap_or_else(|e| e.into_inner()) = Some(version);
259    }
260
261    /// Clear the negotiated version — the legacy `initialize` request must carry no
262    /// `MCP-Protocol-Version` header (nothing agreed yet), so this resets what a
263    /// prior modern probe set.
264    pub fn clear_protocol_version(&self) {
265        *self
266            .protocol_version
267            .lock()
268            .unwrap_or_else(|e| e.into_inner()) = None;
269    }
270
271    pub fn scheme(&self) -> &'static str {
272        self.endpoint.scheme()
273    }
274
275    /// Open a fresh connection to the endpoint as a boxed byte stream, applying
276    /// `timeout` as the connect + read/write bound (each request opens its own
277    /// connection, so the per-call timeout governs the whole exchange).
278    fn connect(&self, timeout: Duration) -> Result<Box<dyn http::Stream>, HttpError> {
279        match &self.endpoint {
280            McpEndpoint::Tcp {
281                host, port, tls, ..
282            } => {
283                let tcp = http::connect_tcp(host, *port, timeout).map_err(HttpError::Connect)?;
284                if *tls {
285                    #[cfg(feature = "tls")]
286                    {
287                        let s = net::tls::connect(tcp, host, self.identity.as_ref())
288                            .map_err(HttpError::Connect)?;
289                        Ok(Box::new(s))
290                    }
291                    #[cfg(not(feature = "tls"))]
292                    {
293                        Err(HttpError::Unsupported(
294                            "https:// MCP requires building with --features tls".into(),
295                        ))
296                    }
297                } else {
298                    Ok(Box::new(tcp))
299                }
300            }
301            McpEndpoint::Unix { socket, .. } => {
302                // `net::unixsock::connect` exists on every platform (a non-unix
303                // build returns an Unsupported error), matching the intel path.
304                let s = net::unixsock::connect(socket, timeout).map_err(HttpError::Connect)?;
305                Ok(Box::new(s))
306            }
307            McpEndpoint::Vsock { cid, port, .. } => {
308                #[cfg(feature = "vsock")]
309                {
310                    let s =
311                        net::vsock::connect(*cid, *port, timeout).map_err(HttpError::Connect)?;
312                    Ok(Box::new(s))
313                }
314                #[cfg(not(feature = "vsock"))]
315                {
316                    let _ = (cid, port);
317                    Err(HttpError::Unsupported(
318                        "vsock: MCP requires building with --features vsock".into(),
319                    ))
320                }
321            }
322        }
323    }
324
325    /// The AAuth headers for ONE dial (RFC 0023): the signature over
326    /// `@method`/`@authority`/`@path` (over `body` too when the server requires a
327    /// content-digest cover), plus the optional `AAuth-Capabilities` advert.
328    /// Empty without a signer — the endpoint is then called unsigned.
329    ///
330    /// Every dial goes through here, not just the request POST: a signed server
331    /// answers an unsigned dial with a challenge, and on the long-lived
332    /// notification stream that failure is silent in the worst way — the daemon
333    /// keeps answering requests and simply never wakes.
334    fn auth_headers(&self, method: &str, body: &[u8]) -> Vec<(String, String)> {
335        match &self.signer {
336            Some(s) => {
337                let authority = self.endpoint.host_header();
338                let mut sig = s.sign(method, authority, self.endpoint.http_path(), body);
339                if let Some(caps) = s.capabilities() {
340                    sig.push(("AAuth-Capabilities".into(), caps));
341                }
342                sig
343            }
344            None => Vec::new(),
345        }
346    }
347
348    /// POST one JSON-RPC message. For a REQUEST (`id` present), return the JSON-RPC
349    /// response with the matching id — parsed from the `application/json` body or
350    /// pumped out of the `text/event-stream` (queuing any interleaved
351    /// notifications via `on_notification`). For a NOTIFICATION (`id` absent), the
352    /// server replies `202 Accepted` with no body and `Ok(None)` is returned.
353    /// Captures/echoes `Mcp-Session-Id`.
354    pub fn send<F: FnMut(Value)>(
355        &self,
356        request_id: Option<i64>,
357        body: &[u8],
358        timeout: Duration,
359        extra_headers: &[(&str, &str)],
360        mut on_notification: F,
361    ) -> Result<Option<Value>, HttpError> {
362        // AAuth request loop (RFC 0023 §5): send signed; if the server answers
363        // with an `AAuth-Requirement` the signer can satisfy (adopt an access
364        // token, run the Person-Server exchange), re-sign and retry — bounded,
365        // so a mis-satisfied requirement cannot spin. Without a signer this is
366        // exactly one pass.
367        const MAX_AUTH_ATTEMPTS: usize = 3;
368        let mut attempt = 0;
369        loop {
370            attempt += 1;
371            match self.send_once(
372                request_id,
373                body,
374                timeout,
375                extra_headers,
376                &mut on_notification,
377            )? {
378                SendOutcome::Result(v) => return Ok(v),
379                SendOutcome::Error(e) => return Err(e),
380                SendOutcome::RetryAuth if attempt < MAX_AUTH_ATTEMPTS => continue,
381                // Out of retries: re-send once more unsigned-of-retry to surface
382                // the server's real error rather than looping.
383                SendOutcome::RetryAuth => {
384                    return match self.send_once(
385                        request_id,
386                        body,
387                        timeout,
388                        extra_headers,
389                        &mut on_notification,
390                    )? {
391                        SendOutcome::Result(v) => Ok(v),
392                        SendOutcome::Error(e) => Err(e),
393                        SendOutcome::RetryAuth => Err(HttpError::NoResponse),
394                    };
395                }
396            }
397        }
398    }
399
400    /// One send attempt: build headers (+ AAuth signing), POST, and classify the
401    /// response — a parsed result, a terminal error, or `RetryAuth` (the signer
402    /// satisfied an `AAuth-Requirement`; the caller re-signs and retries).
403    fn send_once<F: FnMut(Value)>(
404        &self,
405        request_id: Option<i64>,
406        body: &[u8],
407        timeout: Duration,
408        extra_headers: &[(&str, &str)],
409        on_notification: &mut F,
410    ) -> Result<SendOutcome, HttpError> {
411        let mut stream = self.connect(timeout)?;
412        let mut headers: Vec<(&str, &str)> = vec![
413            ("Content-Type", "application/json"),
414            ("Accept", "application/json, text/event-stream"),
415        ];
416        let session = self
417            .session
418            .lock()
419            .unwrap_or_else(|e| e.into_inner())
420            .clone();
421        if let Some(sid) = &session {
422            headers.push(("Mcp-Session-Id", sid));
423        }
424        // MCP-Protocol-Version on every post-initialize request (a Streamable HTTP
425        // MUST). `None` only before/at initialize, when no version is agreed yet.
426        let protocol = self
427            .protocol_version
428            .lock()
429            .unwrap_or_else(|e| e.into_inner())
430            .clone();
431        if let Some(v) = &protocol {
432            headers.push(("MCP-Protocol-Version", v));
433        }
434        // Caller-supplied per-request headers (the modern era's Mcp-Method /
435        // Mcp-Name routing headers).
436        for (k, v) in extra_headers {
437            headers.push((k, v));
438        }
439        for (k, v) in &self.headers {
440            headers.push((k.as_str(), v.as_str()));
441        }
442        // AAuth request signing (RFC 0023). Owned strings kept alive in `signed`
443        // for the borrow.
444        let signed = self.auth_headers("POST", body);
445        for (k, v) in &signed {
446            headers.push((k.as_str(), v.as_str()));
447        }
448
449        let resp = http::send_streaming(
450            stream.as_mut(),
451            self.endpoint.host_header(),
452            "POST",
453            self.endpoint.http_path(),
454            &headers,
455            body,
456        )
457        .map_err(HttpError::Http)?;
458
459        // Adopt a server-assigned session id (initialize response).
460        if let Some(sid) = resp.header("mcp-session-id") {
461            *self.session.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string());
462        }
463
464        // AAuth response reaction (RFC 0023 §5): let the signer adopt an access
465        // token / satisfy a requirement. `on_response` returns whether a retry
466        // would now differ. Only consulted when a signer is present AND the
467        // response carries an AAuth signal (a requirement, an access token, or a
468        // 401/202) — a plain success skips it.
469        if let Some(signer) = &self.signer {
470            let ar = AuthResponse {
471                status: resp.status,
472                requirement: resp.header("aauth-requirement").map(str::to_string),
473                access: resp.header("aauth-access").map(str::to_string),
474                location: resp.header("location").map(str::to_string),
475                error: resp
476                    .header("signature-error")
477                    .or_else(|| resp.header("aauth-error"))
478                    .map(str::to_string),
479            };
480            if ar.requirement.is_some()
481                || ar.access.is_some()
482                || resp.status == 401
483                || resp.status == 202
484            {
485                let authority = self.endpoint.host_header().to_string();
486                if signer.on_response(&ar, &authority) {
487                    return Ok(SendOutcome::RetryAuth);
488                }
489            }
490        }
491
492        if !resp.is_success() {
493            // Capture the body so the caller can classify a modern JSON-RPC error.
494            let status = resp.status;
495            let body = resp.into_body().unwrap_or_default();
496            return Ok(SendOutcome::Error(HttpError::Status(status, body)));
497        }
498
499        // A notification POST is acknowledged with an empty body (often 202).
500        if request_id.is_none() {
501            return Ok(SendOutcome::Result(None));
502        }
503
504        if resp.is_event_stream() {
505            let mut sse = resp.sse();
506            while let Some(ev) = sse.next_event().map_err(HttpError::Http)? {
507                if let Some(msg) = route_message(&ev, request_id, on_notification) {
508                    return Ok(SendOutcome::Result(Some(msg)));
509                }
510            }
511            Ok(SendOutcome::Error(HttpError::NoResponse))
512        } else {
513            let bytes = resp.into_body().map_err(HttpError::Http)?;
514            let v: Value = serde_json::from_slice(&bytes)
515                .map_err(|e| HttpError::Http(io::Error::new(io::ErrorKind::InvalidData, e)))?;
516            Ok(SendOutcome::Result(Some(v)))
517        }
518    }
519
520    /// Open the long-lived server→client notification stream: a `GET` that the
521    /// server answers with `text/event-stream`, carrying JSON-RPC notifications
522    /// (e.g. `resources/updated`). Returns an owning SSE reader. `read_timeout`
523    /// bounds each read so the caller's loop can poll a stop flag between events
524    /// (clean shutdown). Errors if the server has no push channel (non-2xx or a
525    /// non-SSE response) — the caller then runs without server-initiated pushes.
526    /// The session id the server assigned, if this connection has one.
527    pub fn session_id(&self) -> Option<String> {
528        self.session
529            .lock()
530            .unwrap_or_else(|e| e.into_inner())
531            .clone()
532    }
533
534    pub fn open_events(&self, read_timeout: Duration) -> Result<EventStream, HttpError> {
535        let stream = self.connect(read_timeout)?;
536        let mut headers: Vec<(&str, &str)> = vec![("Accept", "text/event-stream")];
537        let session = self
538            .session
539            .lock()
540            .unwrap_or_else(|e| e.into_inner())
541            .clone();
542        if let Some(sid) = &session {
543            headers.push(("Mcp-Session-Id", sid));
544        }
545        // The notification stream is opened post-initialize (from subscribe), so
546        // the negotiated version is always known here (Streamable HTTP MUST).
547        let protocol = self
548            .protocol_version
549            .lock()
550            .unwrap_or_else(|e| e.into_inner())
551            .clone();
552        if let Some(v) = &protocol {
553            headers.push(("MCP-Protocol-Version", v));
554        }
555        for (k, v) in &self.headers {
556            headers.push((k.as_str(), v.as_str()));
557        }
558        // The push channel is signed exactly like the request path — an
559        // `auth:`-configured server rejects an unsigned GET, and losing this
560        // stream costs the daemon its whole reactivity story. The
561        // challenge/re-sign loop stays on the POST path: this dial only ever
562        // happens post-initialize, so the signer has already satisfied whatever
563        // the server required and signs from what it learned there.
564        let signed = self.auth_headers("GET", b"");
565        for (k, v) in &signed {
566            headers.push((k.as_str(), v.as_str()));
567        }
568        let resp = http::send_streaming(
569            stream,
570            self.endpoint.host_header(),
571            "GET",
572            self.endpoint.http_path(),
573            &headers,
574            b"",
575        )
576        .map_err(HttpError::Http)?;
577        if !resp.is_success() {
578            let status = resp.status;
579            let body = resp.into_body().unwrap_or_default();
580            return Err(HttpError::Status(status, body));
581        }
582        if !resp.is_event_stream() {
583            return Err(HttpError::Unsupported(
584                "server has no GET SSE notification stream".into(),
585            ));
586        }
587        Ok(resp.sse())
588    }
589
590    /// Open the MODERN long-lived notification stream via a `subscriptions/listen`
591    /// POST (the stateless replacement for the removed GET stream). `body` is the
592    /// full pre-built JSON-RPC request (its `_meta` already injected); `routing`
593    /// are the Mcp-Method/Mcp-Name headers. The server answers with an SSE stream
594    /// that stays open, carrying the opted-in notifications; returns its reader.
595    pub fn open_listen(
596        &self,
597        read_timeout: Duration,
598        body: &[u8],
599        routing: &[(&str, &str)],
600    ) -> Result<EventStream, HttpError> {
601        let stream = self.connect(read_timeout)?;
602        let mut headers: Vec<(&str, &str)> = vec![
603            ("Content-Type", "application/json"),
604            ("Accept", "text/event-stream"),
605        ];
606        let protocol = self
607            .protocol_version
608            .lock()
609            .unwrap_or_else(|e| e.into_inner())
610            .clone();
611        if let Some(v) = &protocol {
612            headers.push(("MCP-Protocol-Version", v));
613        }
614        for (k, v) in routing {
615            headers.push((k, v));
616        }
617        for (k, v) in &self.headers {
618            headers.push((k.as_str(), v.as_str()));
619        }
620        // Signed like any other POST: the modern era's listen stream IS the
621        // notification channel, so an unsigned dial loses reactivity the same way.
622        let signed = self.auth_headers("POST", body);
623        for (k, v) in &signed {
624            headers.push((k.as_str(), v.as_str()));
625        }
626        let resp = http::send_streaming(
627            stream,
628            self.endpoint.host_header(),
629            "POST",
630            self.endpoint.http_path(),
631            &headers,
632            body,
633        )
634        .map_err(HttpError::Http)?;
635        if !resp.is_success() {
636            let status = resp.status;
637            let body = resp.into_body().unwrap_or_default();
638            return Err(HttpError::Status(status, body));
639        }
640        if !resp.is_event_stream() {
641            return Err(HttpError::Unsupported(
642                "subscriptions/listen did not return an SSE stream".into(),
643            ));
644        }
645        Ok(resp.sse())
646    }
647}
648
649/// An owning SSE reader over the notification `GET` stream (a boxed transport
650/// stream, so it survives on the notification thread).
651pub type EventStream = http::SseReader<std::io::BufReader<Box<dyn http::Stream>>>;
652
653/// Route one SSE event: if its `data` is the JSON-RPC response for `request_id`,
654/// return it; a message without a matching id (a notification/other) is handed to
655/// `on_notification` and `None` is returned so the caller keeps reading.
656fn route_message<F: FnMut(Value)>(
657    ev: &SseEvent,
658    request_id: Option<i64>,
659    on_notification: &mut F,
660) -> Option<Value> {
661    let v: Value = serde_json::from_str(&ev.data).ok()?;
662    let id_matches =
663        matches!((request_id, v.get("id").and_then(Value::as_i64)), (Some(a), Some(b)) if a == b);
664    if id_matches {
665        Some(v)
666    } else {
667        on_notification(v);
668        None
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn parse_https_endpoint() {
678        let e = McpEndpoint::parse("https://mcp.example.com/mcp").unwrap();
679        assert_eq!(e.scheme(), "https");
680        assert_eq!(e.http_path(), "/mcp");
681        assert_eq!(e.host_header(), "mcp.example.com");
682        match e {
683            McpEndpoint::Tcp {
684                host, port, tls, ..
685            } => {
686                assert_eq!(host, "mcp.example.com");
687                assert_eq!(port, 443);
688                assert!(tls);
689            }
690            _ => panic!("expected Tcp"),
691        }
692    }
693
694    #[test]
695    fn parse_http_unix_vsock() {
696        assert_eq!(
697            McpEndpoint::parse("http://localhost:8080/mcp")
698                .unwrap()
699                .scheme(),
700            "http"
701        );
702        let u = McpEndpoint::parse("unix:/run/fs.sock").unwrap();
703        assert_eq!(u.scheme(), "unix");
704        assert_eq!(u.host_header(), "localhost");
705        assert_eq!(u.http_path(), "/");
706        let v = McpEndpoint::parse("vsock:3:5000").unwrap();
707        assert_eq!(v.scheme(), "vsock");
708        assert!(matches!(
709            v,
710            McpEndpoint::Vsock {
711                cid: 3,
712                port: 5000,
713                ..
714            }
715        ));
716    }
717
718    #[test]
719    fn parse_rejects_bad_endpoints() {
720        assert!(McpEndpoint::parse("unix:").is_err());
721        assert!(McpEndpoint::parse("vsock:nope").is_err());
722        assert!(McpEndpoint::parse("ftp://x/").is_err());
723    }
724
725    #[test]
726    fn route_message_matches_response_id_and_queues_notifications() {
727        let mut notes: Vec<Value> = Vec::new();
728        // A notification (no id) is queued, returns None.
729        let n = SseEvent {
730            data: r#"{"jsonrpc":"2.0","method":"notifications/message","params":{}}"#.into(),
731            ..Default::default()
732        };
733        assert!(route_message(&n, Some(1), &mut |v| notes.push(v)).is_none());
734        assert_eq!(notes.len(), 1);
735        // The matching-id response is returned.
736        let r = SseEvent {
737            data: r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#.into(),
738            ..Default::default()
739        };
740        let got = route_message(&r, Some(1), &mut |v| notes.push(v)).expect("response");
741        assert_eq!(got["result"]["ok"], true);
742        assert_eq!(notes.len(), 1, "response is not queued as a notification");
743    }
744}