Skip to main content

mcp/
http.rs

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