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    /// POST one JSON-RPC message. For a REQUEST (`id` present), return the JSON-RPC
326    /// response with the matching id — parsed from the `application/json` body or
327    /// pumped out of the `text/event-stream` (queuing any interleaved
328    /// notifications via `on_notification`). For a NOTIFICATION (`id` absent), the
329    /// server replies `202 Accepted` with no body and `Ok(None)` is returned.
330    /// Captures/echoes `Mcp-Session-Id`.
331    pub fn send<F: FnMut(Value)>(
332        &self,
333        request_id: Option<i64>,
334        body: &[u8],
335        timeout: Duration,
336        extra_headers: &[(&str, &str)],
337        mut on_notification: F,
338    ) -> Result<Option<Value>, HttpError> {
339        // AAuth request loop (RFC 0023 §5): send signed; if the server answers
340        // with an `AAuth-Requirement` the signer can satisfy (adopt an access
341        // token, run the Person-Server exchange), re-sign and retry — bounded,
342        // so a mis-satisfied requirement cannot spin. Without a signer this is
343        // exactly one pass.
344        const MAX_AUTH_ATTEMPTS: usize = 3;
345        let mut attempt = 0;
346        loop {
347            attempt += 1;
348            match self.send_once(
349                request_id,
350                body,
351                timeout,
352                extra_headers,
353                &mut on_notification,
354            )? {
355                SendOutcome::Result(v) => return Ok(v),
356                SendOutcome::Error(e) => return Err(e),
357                SendOutcome::RetryAuth if attempt < MAX_AUTH_ATTEMPTS => continue,
358                // Out of retries: re-send once more unsigned-of-retry to surface
359                // the server's real error rather than looping.
360                SendOutcome::RetryAuth => {
361                    return match self.send_once(
362                        request_id,
363                        body,
364                        timeout,
365                        extra_headers,
366                        &mut on_notification,
367                    )? {
368                        SendOutcome::Result(v) => Ok(v),
369                        SendOutcome::Error(e) => Err(e),
370                        SendOutcome::RetryAuth => Err(HttpError::NoResponse),
371                    };
372                }
373            }
374        }
375    }
376
377    /// One send attempt: build headers (+ AAuth signing), POST, and classify the
378    /// response — a parsed result, a terminal error, or `RetryAuth` (the signer
379    /// satisfied an `AAuth-Requirement`; the caller re-signs and retries).
380    fn send_once<F: FnMut(Value)>(
381        &self,
382        request_id: Option<i64>,
383        body: &[u8],
384        timeout: Duration,
385        extra_headers: &[(&str, &str)],
386        on_notification: &mut F,
387    ) -> Result<SendOutcome, HttpError> {
388        let mut stream = self.connect(timeout)?;
389        let mut headers: Vec<(&str, &str)> = vec![
390            ("Content-Type", "application/json"),
391            ("Accept", "application/json, text/event-stream"),
392        ];
393        let session = self
394            .session
395            .lock()
396            .unwrap_or_else(|e| e.into_inner())
397            .clone();
398        if let Some(sid) = &session {
399            headers.push(("Mcp-Session-Id", sid));
400        }
401        // MCP-Protocol-Version on every post-initialize request (a Streamable HTTP
402        // MUST). `None` only before/at initialize, when no version is agreed yet.
403        let protocol = self
404            .protocol_version
405            .lock()
406            .unwrap_or_else(|e| e.into_inner())
407            .clone();
408        if let Some(v) = &protocol {
409            headers.push(("MCP-Protocol-Version", v));
410        }
411        // Caller-supplied per-request headers (the modern era's Mcp-Method /
412        // Mcp-Name routing headers).
413        for (k, v) in extra_headers {
414            headers.push((k, v));
415        }
416        for (k, v) in &self.headers {
417            headers.push((k.as_str(), v.as_str()));
418        }
419        // AAuth request signing (RFC 0023): sign over @method/@authority/@path
420        // (+ content-digest when the server requires it). Owned strings kept
421        // alive in `signed` for the borrow.
422        let signed: Vec<(String, String)> = match &self.signer {
423            Some(s) => {
424                let authority = self.endpoint.host_header();
425                let mut sig = s.sign("POST", authority, self.endpoint.http_path(), body);
426                if let Some(caps) = s.capabilities() {
427                    sig.push(("AAuth-Capabilities".into(), caps));
428                }
429                sig
430            }
431            None => Vec::new(),
432        };
433        for (k, v) in &signed {
434            headers.push((k.as_str(), v.as_str()));
435        }
436
437        let resp = http::send_streaming(
438            stream.as_mut(),
439            self.endpoint.host_header(),
440            "POST",
441            self.endpoint.http_path(),
442            &headers,
443            body,
444        )
445        .map_err(HttpError::Http)?;
446
447        // Adopt a server-assigned session id (initialize response).
448        if let Some(sid) = resp.header("mcp-session-id") {
449            *self.session.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string());
450        }
451
452        // AAuth response reaction (RFC 0023 §5): let the signer adopt an access
453        // token / satisfy a requirement. `on_response` returns whether a retry
454        // would now differ. Only consulted when a signer is present AND the
455        // response carries an AAuth signal (a requirement, an access token, or a
456        // 401/202) — a plain success skips it.
457        if let Some(signer) = &self.signer {
458            let ar = AuthResponse {
459                status: resp.status,
460                requirement: resp.header("aauth-requirement").map(str::to_string),
461                access: resp.header("aauth-access").map(str::to_string),
462                location: resp.header("location").map(str::to_string),
463                error: resp
464                    .header("signature-error")
465                    .or_else(|| resp.header("aauth-error"))
466                    .map(str::to_string),
467            };
468            if ar.requirement.is_some()
469                || ar.access.is_some()
470                || resp.status == 401
471                || resp.status == 202
472            {
473                let authority = self.endpoint.host_header().to_string();
474                if signer.on_response(&ar, &authority) {
475                    return Ok(SendOutcome::RetryAuth);
476                }
477            }
478        }
479
480        if !resp.is_success() {
481            // Capture the body so the caller can classify a modern JSON-RPC error.
482            let status = resp.status;
483            let body = resp.into_body().unwrap_or_default();
484            return Ok(SendOutcome::Error(HttpError::Status(status, body)));
485        }
486
487        // A notification POST is acknowledged with an empty body (often 202).
488        if request_id.is_none() {
489            return Ok(SendOutcome::Result(None));
490        }
491
492        if resp.is_event_stream() {
493            let mut sse = resp.sse();
494            while let Some(ev) = sse.next_event().map_err(HttpError::Http)? {
495                if let Some(msg) = route_message(&ev, request_id, on_notification) {
496                    return Ok(SendOutcome::Result(Some(msg)));
497                }
498            }
499            Ok(SendOutcome::Error(HttpError::NoResponse))
500        } else {
501            let bytes = resp.into_body().map_err(HttpError::Http)?;
502            let v: Value = serde_json::from_slice(&bytes)
503                .map_err(|e| HttpError::Http(io::Error::new(io::ErrorKind::InvalidData, e)))?;
504            Ok(SendOutcome::Result(Some(v)))
505        }
506    }
507
508    /// Open the long-lived server→client notification stream: a `GET` that the
509    /// server answers with `text/event-stream`, carrying JSON-RPC notifications
510    /// (e.g. `resources/updated`). Returns an owning SSE reader. `read_timeout`
511    /// bounds each read so the caller's loop can poll a stop flag between events
512    /// (clean shutdown). Errors if the server has no push channel (non-2xx or a
513    /// non-SSE response) — the caller then runs without server-initiated pushes.
514    /// The session id the server assigned, if this connection has one.
515    pub fn session_id(&self) -> Option<String> {
516        self.session
517            .lock()
518            .unwrap_or_else(|e| e.into_inner())
519            .clone()
520    }
521
522    pub fn open_events(&self, read_timeout: Duration) -> Result<EventStream, HttpError> {
523        let stream = self.connect(read_timeout)?;
524        let mut headers: Vec<(&str, &str)> = vec![("Accept", "text/event-stream")];
525        let session = self
526            .session
527            .lock()
528            .unwrap_or_else(|e| e.into_inner())
529            .clone();
530        if let Some(sid) = &session {
531            headers.push(("Mcp-Session-Id", sid));
532        }
533        // The notification stream is opened post-initialize (from subscribe), so
534        // the negotiated version is always known here (Streamable HTTP MUST).
535        let protocol = self
536            .protocol_version
537            .lock()
538            .unwrap_or_else(|e| e.into_inner())
539            .clone();
540        if let Some(v) = &protocol {
541            headers.push(("MCP-Protocol-Version", v));
542        }
543        for (k, v) in &self.headers {
544            headers.push((k.as_str(), v.as_str()));
545        }
546        let resp = http::send_streaming(
547            stream,
548            self.endpoint.host_header(),
549            "GET",
550            self.endpoint.http_path(),
551            &headers,
552            b"",
553        )
554        .map_err(HttpError::Http)?;
555        if !resp.is_success() {
556            let status = resp.status;
557            let body = resp.into_body().unwrap_or_default();
558            return Err(HttpError::Status(status, body));
559        }
560        if !resp.is_event_stream() {
561            return Err(HttpError::Unsupported(
562                "server has no GET SSE notification stream".into(),
563            ));
564        }
565        Ok(resp.sse())
566    }
567
568    /// Open the MODERN long-lived notification stream via a `subscriptions/listen`
569    /// POST (the stateless replacement for the removed GET stream). `body` is the
570    /// full pre-built JSON-RPC request (its `_meta` already injected); `routing`
571    /// are the Mcp-Method/Mcp-Name headers. The server answers with an SSE stream
572    /// that stays open, carrying the opted-in notifications; returns its reader.
573    pub fn open_listen(
574        &self,
575        read_timeout: Duration,
576        body: &[u8],
577        routing: &[(&str, &str)],
578    ) -> Result<EventStream, HttpError> {
579        let stream = self.connect(read_timeout)?;
580        let mut headers: Vec<(&str, &str)> = vec![
581            ("Content-Type", "application/json"),
582            ("Accept", "text/event-stream"),
583        ];
584        let protocol = self
585            .protocol_version
586            .lock()
587            .unwrap_or_else(|e| e.into_inner())
588            .clone();
589        if let Some(v) = &protocol {
590            headers.push(("MCP-Protocol-Version", v));
591        }
592        for (k, v) in routing {
593            headers.push((k, v));
594        }
595        for (k, v) in &self.headers {
596            headers.push((k.as_str(), v.as_str()));
597        }
598        let resp = http::send_streaming(
599            stream,
600            self.endpoint.host_header(),
601            "POST",
602            self.endpoint.http_path(),
603            &headers,
604            body,
605        )
606        .map_err(HttpError::Http)?;
607        if !resp.is_success() {
608            let status = resp.status;
609            let body = resp.into_body().unwrap_or_default();
610            return Err(HttpError::Status(status, body));
611        }
612        if !resp.is_event_stream() {
613            return Err(HttpError::Unsupported(
614                "subscriptions/listen did not return an SSE stream".into(),
615            ));
616        }
617        Ok(resp.sse())
618    }
619}
620
621/// An owning SSE reader over the notification `GET` stream (a boxed transport
622/// stream, so it survives on the notification thread).
623pub type EventStream = http::SseReader<std::io::BufReader<Box<dyn http::Stream>>>;
624
625/// Route one SSE event: if its `data` is the JSON-RPC response for `request_id`,
626/// return it; a message without a matching id (a notification/other) is handed to
627/// `on_notification` and `None` is returned so the caller keeps reading.
628fn route_message<F: FnMut(Value)>(
629    ev: &SseEvent,
630    request_id: Option<i64>,
631    on_notification: &mut F,
632) -> Option<Value> {
633    let v: Value = serde_json::from_str(&ev.data).ok()?;
634    let id_matches =
635        matches!((request_id, v.get("id").and_then(Value::as_i64)), (Some(a), Some(b)) if a == b);
636    if id_matches {
637        Some(v)
638    } else {
639        on_notification(v);
640        None
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn parse_https_endpoint() {
650        let e = McpEndpoint::parse("https://mcp.example.com/mcp").unwrap();
651        assert_eq!(e.scheme(), "https");
652        assert_eq!(e.http_path(), "/mcp");
653        assert_eq!(e.host_header(), "mcp.example.com");
654        match e {
655            McpEndpoint::Tcp {
656                host, port, tls, ..
657            } => {
658                assert_eq!(host, "mcp.example.com");
659                assert_eq!(port, 443);
660                assert!(tls);
661            }
662            _ => panic!("expected Tcp"),
663        }
664    }
665
666    #[test]
667    fn parse_http_unix_vsock() {
668        assert_eq!(
669            McpEndpoint::parse("http://localhost:8080/mcp")
670                .unwrap()
671                .scheme(),
672            "http"
673        );
674        let u = McpEndpoint::parse("unix:/run/fs.sock").unwrap();
675        assert_eq!(u.scheme(), "unix");
676        assert_eq!(u.host_header(), "localhost");
677        assert_eq!(u.http_path(), "/");
678        let v = McpEndpoint::parse("vsock:3:5000").unwrap();
679        assert_eq!(v.scheme(), "vsock");
680        assert!(matches!(
681            v,
682            McpEndpoint::Vsock {
683                cid: 3,
684                port: 5000,
685                ..
686            }
687        ));
688    }
689
690    #[test]
691    fn parse_rejects_bad_endpoints() {
692        assert!(McpEndpoint::parse("unix:").is_err());
693        assert!(McpEndpoint::parse("vsock:nope").is_err());
694        assert!(McpEndpoint::parse("ftp://x/").is_err());
695    }
696
697    #[test]
698    fn route_message_matches_response_id_and_queues_notifications() {
699        let mut notes: Vec<Value> = Vec::new();
700        // A notification (no id) is queued, returns None.
701        let n = SseEvent {
702            data: r#"{"jsonrpc":"2.0","method":"notifications/message","params":{}}"#.into(),
703            ..Default::default()
704        };
705        assert!(route_message(&n, Some(1), &mut |v| notes.push(v)).is_none());
706        assert_eq!(notes.len(), 1);
707        // The matching-id response is returned.
708        let r = SseEvent {
709            data: r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#.into(),
710            ..Default::default()
711        };
712        let got = route_message(&r, Some(1), &mut |v| notes.push(v)).expect("response");
713        assert_eq!(got["result"]["ok"], true);
714        assert_eq!(notes.len(), 1, "response is not queued as a notification");
715    }
716}