Skip to main content

protocol_droid/adapters/secondary/transport/http/
client.rs

1//! The Streamable HTTP client: POST, GET, DELETE, and a resume that does not
2//! lose or repeat an event.
3//!
4//! Four things this client knows, and nothing else. Which endpoint to talk to.
5//! Which session the peer issued, captured from a response header and sent on
6//! every request after. Whether a response is one JSON document or a stream.
7//! And, when a stream breaks, which event the peer last actually *delivered* —
8//! because that number, not the number of bytes read, is what a resume asks
9//! for.
10//!
11//! It names no method, no revision, and no capability. What a request *means*
12//! belongs to the layer above; this one moves bytes and keeps the session
13//! handle. A client that grew a method table would be unusable for the bridge,
14//! which has to carry revisions it does not understand.
15//!
16//! Two response shapes, and the choice between them is the peer's, made in the
17//! `Content-Type` and nowhere else. A client that guessed — always streaming,
18//! or always buffering — hangs on one of the two legal answers, and the hang is
19//! indistinguishable from a peer that has stopped talking.
20
21use core::fmt;
22use core::future::Future;
23use core::pin::Pin;
24use core::str::FromStr;
25use core::sync::atomic::{AtomicI64, Ordering};
26use serde_json::{Map, Value};
27use std::collections::VecDeque;
28use std::sync::{Arc, Mutex};
29use std::time::Duration;
30
31use crate::adapters::primary::transport::http::{SseError, SseEvent};
32use crate::application::bridge::Unsolicited;
33use crate::application::client::{ClientTransport, TransportError};
34use crate::application::jsonrpc::document::{
35    notification_document, reply_result, request_document,
36};
37use crate::domain::ir::{JsonObject, MethodName};
38use crate::domain::{EventId, McpSessionId, McpSessionIdError, RequestId};
39
40use super::error::{HttpClientError, preview};
41use super::frames::{FrameError, FrameReader};
42use super::opaque::WireStream;
43use crate::application::client::bearer::BearerAuth;
44use crate::application::client::handler::ServerRequestHandler;
45
46/// Maps a frame failure onto the one enum a caller above this layer matches.
47///
48/// `BlockTooLong` is the one variant that does not come straight from the
49/// shared grammar, so it says what actually happened rather than borrowing a
50/// codec error that would name the wrong thing.
51impl From<FrameError> for HttpClientError {
52    fn from(error: FrameError) -> Self {
53        match error {
54            FrameError::BlockTooLong { .. } => Self::StreamEnded { delivered: 0 },
55            FrameError::Grammar { source } => Self::Frame { source },
56        }
57    }
58}
59
60/// The session header, as the peer spells it.
61const SESSION_HEADER: &str = "mcp-session-id";
62
63/// The authorizer, with a `Debug` that cannot print a token.
64struct Auth(Arc<dyn BearerAuth>);
65
66impl fmt::Debug for Auth {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str("Auth(..)")
69    }
70}
71
72/// The handler, with a `Debug` that does not need the handler to have one.
73struct Handler(Arc<dyn ServerRequestHandler>);
74
75impl fmt::Debug for Handler {
76    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77        formatter.write_str("Handler(..)")
78    }
79}
80
81/// How long to wait before resuming a stream whose server named no `retry`.
82const DEFAULT_RETRY: Duration = Duration::from_secs(1);
83
84/// How many times one answer may be resumed before the client gives up.
85const MAX_RESUMES: u32 = 5;
86
87/// Whether `frame` is a request (it has a method and an id), not a response.
88fn is_request(frame: &Map<String, Value>) -> bool {
89    frame.contains_key("method") && frame.contains_key("id")
90}
91
92/// The revision header every request after the handshake carries.
93const PROTOCOL_HEADER: &str = "mcp-protocol-version";
94/// The resume header, as the peer spells it.
95const LAST_EVENT_ID: &str = "last-event-id";
96/// The `POST` verb, named once so an error can say what was in flight.
97const POST: &str = "POST";
98/// The `GET` verb, likewise.
99const GET: &str = "GET";
100/// The `DELETE` verb, likewise.
101const DELETE: &str = "DELETE";
102
103/// What a client accepts, because the peer may answer either way.
104///
105/// A client that sends only `application/json` never sees a stream, and one
106/// that sends only `text/event-stream` cannot get a single response without
107/// opening a stream to read one line. Both are legal and both are worse than
108/// asking for what the client can actually read.
109const ACCEPT: &str = "application/json, text/event-stream";
110/// The media type that means "one JSON document, stream now closed".
111const JSON: &str = "application/json";
112/// The media type that means "an open event stream".
113const EVENT_STREAM: &str = "text/event-stream";
114/// The largest `Last-Event-ID` this client will put in a header.
115///
116/// A bound on our own rendering rather than on the peer's, so a future id type
117/// that renders longer fails here — where the error can name the cursor — and
118/// not at send time, where it would name the request.
119const MAX_CURSOR: usize = 32;
120
121/// How a client is told to behave.
122///
123/// # Examples
124///
125/// ```
126/// use std::time::Duration;
127///
128/// use protocol_droid::adapters::secondary::transport::http::{
129///     HttpClient, HttpClientConfig, Resumable, RetryBudget,
130/// };
131///
132/// let config = HttpClientConfig::new("https://example.invalid/mcp")?
133///     .with_request_timeout(Duration::from_secs(30));
134/// // Building a client opens no socket; the first call does.
135/// let _client = HttpClient::new(config)?;
136/// let _policy = Resumable {
137///     budget: RetryBudget::new(3),
138/// };
139/// # Ok::<(), protocol_droid::adapters::secondary::transport::http::HttpClientError>(())
140/// ```
141#[derive(Debug, Clone)]
142pub struct HttpClientConfig {
143    endpoint: String,
144    request_timeout: Option<Duration>,
145}
146
147impl HttpClientConfig {
148    /// A configuration pointed at `endpoint`.
149    ///
150    /// # Errors
151    ///
152    /// [`HttpClientError::Url`] if `endpoint` is not a URL a request can be
153    /// built against. Checked here rather than at the first call, so a typo is
154    /// a construction error and not a surprise on the wire.
155    pub fn new(endpoint: &str) -> Result<Self, HttpClientError> {
156        // Parsed for the check and then dropped: `reqwest` re-parses on every
157        // request, and holding a second representation of the same string is
158        // one more thing that can disagree with the first.
159        reqwest::Url::parse(endpoint).map_err(|error| HttpClientError::Url {
160            got: endpoint.to_owned(),
161            reason: error.to_string(),
162        })?;
163        Ok(Self {
164            endpoint: endpoint.to_owned(),
165            request_timeout: None,
166        })
167    }
168
169    /// Bounds how long one request may take, reading its body included.
170    #[must_use]
171    pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
172        self.request_timeout = Some(timeout);
173        self
174    }
175
176    /// Removes the request timeout, so a stream may stay open indefinitely.
177    ///
178    /// The default is no timeout, because a streamable event stream is
179    /// long-lived by definition and a timeout on one is a reconnect loop.
180    #[must_use]
181    pub const fn without_request_timeout(mut self) -> Self {
182        self.request_timeout = None;
183        self
184    }
185
186    /// The endpoint this configuration points at.
187    #[must_use]
188    pub fn endpoint(&self) -> &str {
189        &self.endpoint
190    }
191
192    /// The request timeout, if one is set.
193    #[must_use]
194    pub const fn request_timeout(&self) -> Option<Duration> {
195        self.request_timeout
196    }
197}
198
199/// How many times a resumable read will reconnect before reporting failure.
200///
201/// A plain integer, and plain on purpose: the temptation to make this clever is
202/// exactly how a resume becomes a loop. Every reconnect has to be a bounded
203/// number of attempts, and the bound has to be visible at the call site rather
204/// than buried in a backoff policy.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct RetryBudget {
207    attempts: u32,
208}
209
210impl RetryBudget {
211    /// A budget of `attempts` reconnects.
212    #[must_use]
213    pub const fn new(attempts: u32) -> Self {
214        Self { attempts }
215    }
216
217    /// How many reconnects this budget allows.
218    #[must_use]
219    pub const fn attempts(self) -> u32 {
220        self.attempts
221    }
222}
223
224impl Default for RetryBudget {
225    /// Three: enough to ride out a redeploy, few enough that a peer which is
226    /// genuinely gone is reported rather than retried until a human notices.
227    fn default() -> Self {
228        Self::new(3)
229    }
230}
231
232/// How a resumable read behaves when its stream drops.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub struct Resumable {
235    /// How many reconnects are allowed.
236    pub budget: RetryBudget,
237}
238
239/// A client for one endpoint.
240///
241/// Holds the session the peer issued, so the type is `Send + Sync` and one
242/// client can drive concurrent requests. That is the point: the session id is
243/// the shared state, and it is a header rather than a connection, so two
244/// in-flight requests sharing it is normal and not a race.
245#[derive(Debug)]
246pub struct HttpClient {
247    inner: reqwest::Client,
248    config: HttpClientConfig,
249    /// The id the server issued, verbatim.
250    ///
251    /// Opaque on purpose. The specification asks only that it be visible
252    /// ASCII; the sixteen-digit form is this crate's own server's choice, and a
253    /// client that insisted on it could talk to nobody else.
254    session: Mutex<Option<Arc<str>>>,
255    /// The id to put on the next request.
256    ///
257    /// A counter and not a random value, for the reason `StdioClient` uses
258    /// one: the domain does not call a generator, so a client's ids are as
259    /// reproducible as its configuration, and two requests in flight on one
260    /// client can never collide.
261    next_id: AtomicI64,
262    /// The revision the handshake settled on, once it has, as the
263    /// `MCP-Protocol-Version` header carries it.
264    ///
265    /// `2025-06-18` made the header mandatory on every request after
266    /// `initialize`; a client that never sent it was relying on servers that
267    /// tolerate its absence, which a conforming one is not obliged to do.
268    protocol: Mutex<Option<String>>,
269    /// Answers the requests a server sends this client, when one is wired.
270    ///
271    /// Absent means such a request is parked with the other volunteered frames
272    /// — which is what a bridge wants, since it forwards them to its own
273    /// client instead of answering them itself.
274    handler: Option<Handler>,
275    /// Supplies the `Authorization` header, and answers a `401` or `403`.
276    auth: Option<Auth>,
277    /// Frames a server volunteered on a response body this client was reading
278    /// for something else.
279    ///
280    /// A server may answer one `POST` with a stream carrying the reply *and*
281    /// any number of messages it wanted to send anyway. Those are real
282    /// traffic and they are not this request's answer, so they are held here
283    /// and handed to the bridge rather than dropped.
284    pub(crate) unsolicited: Arc<Unsolicited>,
285}
286
287impl HttpClient {
288    /// Builds a client. Opens no socket.
289    ///
290    /// # Errors
291    ///
292    /// Propagates the builder's own failure, which is a TLS backend that could
293    /// not be initialised rather than anything about the endpoint.
294    pub fn new(config: HttpClientConfig) -> Result<Self, HttpClientError> {
295        let mut builder = reqwest::Client::builder()
296            .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")));
297        // `timeout` takes a `Duration`, not an `Option`, so an absent timeout
298        // is the builder's own default of none rather than a value to undo.
299        if let Some(limit) = config.request_timeout {
300            builder = builder.timeout(limit);
301        }
302        let inner = builder.build().map_err(|error| HttpClientError::Io {
303            method: "CLIENT",
304            error,
305        })?;
306        Ok(Self::from_client(inner, config))
307    }
308
309    /// Builds a client around a connection pool that already exists.
310    ///
311    /// Exists for the auto-detect step, which holds a `reqwest::Client` before
312    /// it knows which transport the peer speaks — the probe request is the thing
313    /// that tells it. Handing that pool to whichever client wins means one TLS
314    /// handshake and one DNS resolution for the whole connection, rather than a
315    /// second set for the transport that happened to win.
316    ///
317    /// Takes the pool by value and cannot fail, because there is nothing left
318    /// to validate: the configuration was checked by whoever parsed it, and the
319    /// pool is already built.
320    #[must_use]
321    pub fn from_client(inner: reqwest::Client, config: HttpClientConfig) -> Self {
322        Self {
323            inner,
324            config,
325            session: Mutex::new(None),
326            next_id: AtomicI64::new(1),
327            protocol: Mutex::new(None),
328            handler: None,
329            auth: None,
330            unsolicited: Arc::new(Unsolicited::new()),
331        }
332    }
333
334    /// The id for the next request on this client.
335    pub fn next_request_id(&self) -> RequestId {
336        RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed))
337    }
338
339    /// This client, authenticating through `auth`.
340    ///
341    /// Every request carries the bearer token it supplies, and a `401` or
342    /// `403` is handed back to it to recover from before the request is tried
343    /// again.
344    #[must_use]
345    pub fn authorizing(mut self, auth: Arc<dyn BearerAuth>) -> Self {
346        self.auth = Some(Auth(auth));
347        self
348    }
349
350    /// This client, answering the server's requests through `handler`.
351    ///
352    /// Without it a `sampling`, `elicitation` or `roots` request the server
353    /// sends mid-call is parked and never answered, and the tool waiting on it
354    /// waits forever.
355    #[must_use]
356    pub fn answering(mut self, handler: Arc<dyn ServerRequestHandler>) -> Self {
357        self.handler = Some(Handler(handler));
358        self
359    }
360
361    /// A client that already knows the session the peer issued.
362    ///
363    /// For the same probe: its response already carried an `Mcp-Session-Id`, and
364    /// every later request has to carry it. Reconstructing the client from
365    /// scratch to recover that header would mean re-running the handshake, and
366    /// an `initialize` run twice is a second session on a server that is
367    /// counting them.
368    #[must_use]
369    pub fn resuming(self, id: McpSessionId) -> Self {
370        *self
371            .session
372            .lock()
373            .unwrap_or_else(std::sync::PoisonError::into_inner) =
374            Some(Arc::from(id.to_string().as_str()));
375        self
376    }
377
378    /// The configuration this client was built with.
379    #[must_use]
380    pub const fn config(&self) -> &HttpClientConfig {
381        &self.config
382    }
383
384    /// The session id the peer issued, exactly as it wrote it.
385    #[must_use]
386    pub fn session_token(&self) -> Option<Arc<str>> {
387        // Poisoning is not a reason to lose the session id. The value is a
388        // header, no invariant a panic could have broken, and propagating
389        // poisoning would turn one panicking thread into a client that can never
390        // talk to the peer again.
391        self.session
392            .lock()
393            .unwrap_or_else(std::sync::PoisonError::into_inner)
394            .clone()
395    }
396
397    /// The session the peer has issued, if it has issued one *and* it is in
398    /// the form this crate's own server writes.
399    ///
400    /// `None` for a session any other server issued; [`session_token`] is the
401    /// accessor that works against every server.
402    ///
403    /// [`session_token`]: Self::session_token
404    #[must_use]
405    pub fn session_id(&self) -> Option<McpSessionId> {
406        self.session_token()
407            .and_then(|token| McpSessionId::from_str(&token).ok())
408    }
409
410    /// POSTs a body and reads one complete response.
411    ///
412    /// Reads the body in full, so a response the peer chose to frame as a
413    /// stream is **not** acceptable here: its body is open-ended and buffering
414    /// it waits forever. Use [`HttpClient::post_streaming`] for that shape.
415    ///
416    /// # Errors
417    ///
418    /// Any [`HttpClientError`] a request can produce, including
419    /// [`HttpClientError::NoSessionYet`] when `require_session` is set and the
420    /// peer has not issued a session, and [`HttpClientError::MediaType`] when a
421    /// `2xx` came back framed as a stream.
422    pub async fn post(
423        &self,
424        body: &[u8],
425        require_session: bool,
426    ) -> Result<HttpResponse, HttpClientError> {
427        let response = self.send(POST, body, require_session).await?;
428        self.read_document(response, POST).await
429    }
430
431    /// POSTs a whole JSON-RPC envelope and returns the peer's answer envelope.
432    ///
433    /// The envelope-granularity entry point, for a caller that already holds a
434    /// parsed message — a bridge, chiefly. [`ClientTransport::send`] cannot
435    /// serve that, and the difference is not cosmetic:
436    ///
437    /// - `send` *mints* an id from this client's counter. A proxy must carry
438    ///   the downstream id through instead, or the reply cannot be matched to
439    ///   the request the client is still waiting on.
440    /// - `send` returns `reply_result`, which pulls the `result` member out and
441    ///   turns an `error` member into a [`TransportError`]. A proxy needs the
442    ///   whole reply: a peer that declines a request has sent a well-formed
443    ///   message, and re-wrapping it as a transport fault reports a network
444    ///   error for a request the server answered on purpose.
445    ///
446    /// The session lifecycle is this client's, not a second one — the
447    /// `Mcp-Session-Id` this client obtained at handshake is the one every
448    /// forwarded request carries, which is what lets a bridge reuse an
449    /// established upstream session instead of re-handshaking per request.
450    ///
451    /// `require_session` is `false` because a bridge forwards the handshake
452    /// itself, and the first message it forwards is the one that has no session
453    /// yet.
454    ///
455    /// # Errors
456    ///
457    /// Any [`HttpClientError`] a request can produce, plus
458    /// [`HttpClientError::NotAMessage`] when the peer answers `2xx` with a JSON
459    /// body that is not an object.
460    ///
461    /// # Panics
462    ///
463    /// Never. Serialisation and parsing failures are [`HttpClientError`]s.
464    pub async fn post_envelope(
465        &self,
466        envelope: &Map<String, Value>,
467    ) -> Result<Option<Map<String, Value>>, HttpClientError> {
468        self.post_envelope_with(envelope, &[]).await
469    }
470
471    /// [`Self::post_envelope`], with headers added to this request alone.
472    ///
473    /// For the `2026-07-28` request-metadata headers, which describe one body
474    /// and so cannot be set once per client.
475    ///
476    /// # Errors
477    ///
478    /// As [`Self::post_envelope`].
479    pub async fn post_envelope_with(
480        &self,
481        envelope: &Map<String, Value>,
482        headers: &[(String, String)],
483    ) -> Result<Option<Map<String, Value>>, HttpClientError> {
484        let body = serde_json::to_vec(envelope).map_err(|error| HttpClientError::Body {
485            method: "POST",
486            what: "encoded",
487            detail: error.to_string(),
488        })?;
489        // `send` rather than `post`, because `post` insists on a whole
490        // document. The specification lets a server answer a `POST` with
491        // either a JSON document or an event stream, and insisting on the
492        // first here failed every request against a server that chose the
493        // second — which is the choice the specification prefers.
494        let response = self.send_with(POST, &body, false, headers).await?;
495        let wants = envelope.get("id").cloned();
496        // `202 Accepted` is the specification's answer to a notification and
497        // carries no body — so no `Content-Type` either. Asking for one is how
498        // a conforming server's reply to `notifications/initialized` used to
499        // fail the handshake it closes.
500        if response.status() == reqwest::StatusCode::ACCEPTED {
501            return Ok(None);
502        }
503        if Self::shape(&response)? == Shape::Stream {
504            return self.read_streamed(response, wants.as_ref()).await.map(Some);
505        }
506        let raw = response
507            .bytes()
508            .await
509            .map_err(|error| HttpClientError::Io {
510                method: POST,
511                error,
512            })?;
513        let raw: &[u8] = &raw;
514
515        // A notification carries no id, so the peer has nothing to match
516        // against and the body is "received" regardless of shape. The
517        // specification recommends `202` with no body, but a server that
518        // answers with `200` and `null` is acknowledging the same thing, and
519        // treating it as a transport fault makes the closing
520        // `notifications/initialized` of every handshake fail. `None` is
521        // the honest reading for a notification whether the body is empty,
522        // `null`, or anything else that is not a JSON-RPC reply.
523        if wants.is_none() {
524            return Ok(None);
525        }
526
527        if raw.is_empty() {
528            return Ok(None);
529        }
530
531        let text = std::str::from_utf8(raw).map_err(|error| HttpClientError::Body {
532            method: "POST",
533            what: "decoded as UTF-8",
534            detail: error.to_string(),
535        })?;
536        let value: Value = serde_json::from_str(text).map_err(|error| HttpClientError::Body {
537            method: "POST",
538            what: "parsed as JSON",
539            detail: error.to_string(),
540        })?;
541
542        value
543            .as_object()
544            .cloned()
545            .map(Some)
546            .ok_or_else(|| HttpClientError::NotAMessage {
547                got: value.to_string(),
548            })
549    }
550
551    /// Sends an already-assembled notification envelope.
552    ///
553    /// The envelope-granularity counterpart to
554    /// [`ClientTransport::notify`], for a caller that already holds a parsed
555    /// message. A notification is answered with a `202` and no body, so there
556    /// is nothing to read back — this is [`HttpClient::post_envelope`] with
557    /// the reply discarded, which is why it is a method rather than a call the
558    /// adapter has to remember to write correctly itself.
559    ///
560    /// # Errors
561    ///
562    /// [`HttpClientError`] when the envelope cannot be encoded or the `POST`
563    /// does not succeed.
564    pub async fn notify_envelope(
565        &self,
566        envelope: &Map<String, Value>,
567    ) -> Result<(), HttpClientError> {
568        let body = serde_json::to_vec(envelope).map_err(|error| HttpClientError::Body {
569            method: "POST",
570            what: "encoded",
571            detail: error.to_string(),
572        })?;
573        self.post(&body, false).await?;
574        Ok(())
575    }
576
577    /// POSTs a body and accepts either response shape.
578    ///
579    /// # Errors
580    ///
581    /// As [`HttpClient::post`], except that a stream is a success rather than a
582    /// [`HttpClientError::MediaType`].
583    pub async fn post_streaming(
584        &self,
585        body: &[u8],
586        require_session: bool,
587    ) -> Result<HttpReply, HttpClientError> {
588        let response = self.send(POST, body, require_session).await?;
589        match Self::shape(&response)? {
590            Shape::Json => self
591                .read_document(response, POST)
592                .await
593                .map(HttpReply::Document),
594            Shape::Stream => Ok(HttpReply::Stream(HttpStream::new(response))),
595        }
596    }
597
598    /// DELETEs the session, ending it at the peer.
599    ///
600    /// # Errors
601    ///
602    /// [`HttpClientError::NoSessionYet`] when there is no session to end. There
603    /// is nothing here to be idempotent about, so guessing is not on the menu.
604    pub async fn delete(&self) -> Result<HttpResponse, HttpClientError> {
605        let Some(id) = self.session_token() else {
606            return Err(HttpClientError::NoSessionYet);
607        };
608        let mut request = self
609            .inner
610            .delete(&self.config.endpoint)
611            .header(SESSION_HEADER, id.as_ref())
612            .header(reqwest::header::ACCEPT, ACCEPT);
613        if let Some(Auth(auth)) = &self.auth
614            && let Some(token) = auth.bearer()
615        {
616            request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
617        }
618        let response = request.send().await.map_err(|error| HttpClientError::Io {
619            method: DELETE,
620            error,
621        })?;
622        self.read_document(response, DELETE).await
623    }
624
625    /// Opens an event stream from `cursor`, and does not reconnect.
626    ///
627    /// For a stream that has to survive a drop use [`HttpClient::resumable`],
628    /// which is the same read with the reconnect inside it.
629    ///
630    /// # Errors
631    ///
632    /// [`HttpClientError::NoSessionYet`] when `require_session` is set and no
633    /// session has been issued, or [`HttpClientError::MediaType`] if the peer
634    /// did not answer with a stream.
635    pub async fn open_stream(
636        &self,
637        cursor: Option<EventId>,
638        require_session: bool,
639    ) -> Result<HttpStream, HttpClientError> {
640        let response = self.get(cursor, require_session).await?;
641        if Self::shape(&response)? != Shape::Stream {
642            return Err(media_error(&response));
643        }
644        Ok(HttpStream::new(response))
645    }
646
647    /// A read that reconnects itself when the peer's stream breaks.
648    ///
649    /// The three things this gets right, each of which is a way a resume
650    /// silently loses or repeats an event:
651    ///
652    /// - It resumes from the last event **delivered**, never from a byte count
653    ///   and never from the last block *read*. A chunk can carry half an
654    ///   event; resuming from where reading stopped repeats it.
655    /// - An event with **no id** cannot be resumed from, because the peer holds
656    ///   no cursor for it. Resuming from the previous id would make the peer
657    ///   repeat it, so the break is reported instead of papered over.
658    /// - The first failure is not instantly fatal and neither is the hundredth:
659    ///   there is a budget, and running out of it is an error carrying how far
660    ///   the read got.
661    ///
662    /// Nothing is opened until the first [`ResumableStream::next_event`], so
663    /// building one cannot fail and cannot hang.
664    #[must_use]
665    pub const fn resumable(
666        &self,
667        start: Option<EventId>,
668        require_session: bool,
669        policy: Resumable,
670    ) -> ResumableStream<'_> {
671        ResumableStream {
672            client: self,
673            current: None,
674            cursor: start,
675            delivered: 0,
676            attempts: 0,
677            require_session,
678            policy,
679            finished: false,
680        }
681    }
682
683    /// Sends one request, absorbing the session header off the response.
684    async fn send(
685        &self,
686        verb: &'static str,
687        body: &[u8],
688        require_session: bool,
689    ) -> Result<reqwest::Response, HttpClientError> {
690        self.send_with(verb, body, require_session, &[]).await
691    }
692
693    /// [`Self::send`], with headers the caller adds to this request alone.
694    async fn send_with(
695        &self,
696        verb: &'static str,
697        body: &[u8],
698        require_session: bool,
699        extra: &[(String, String)],
700    ) -> Result<reqwest::Response, HttpClientError> {
701        let mut response = self.send_once(verb, body, require_session, extra).await?;
702        loop {
703            if response.status().is_success() {
704                if let Some(Auth(auth)) = &self.auth {
705                    auth.accepted();
706                }
707                return Ok(response);
708            }
709            let status = response.status().as_u16();
710            // 401 and 403 are the two statuses authorization answers with, and
711            // only a client that was given a way to authorize reacts to them.
712            // `challenged` decides how many times: it errors once it has
713            // stopped believing another attempt will work.
714            if matches!(status, 401 | 403)
715                && let Some(Auth(auth)) = &self.auth
716            {
717                let challenge = response
718                    .headers()
719                    .get(reqwest::header::WWW_AUTHENTICATE)
720                    .and_then(|value| value.to_str().ok())
721                    .map(ToOwned::to_owned);
722                auth.challenged(status, challenge.as_deref())
723                    .await
724                    .map_err(|error| HttpClientError::Authorization(error.to_string()))?;
725                response = self.send_once(verb, body, require_session, extra).await?;
726                continue;
727            }
728            return Err(refusal(verb, response).await);
729        }
730    }
731
732    /// One attempt at [`send_with`](Self::send_with), returning whatever came
733    /// back — success or not — for the caller to judge.
734    async fn send_once(
735        &self,
736        verb: &'static str,
737        body: &[u8],
738        require_session: bool,
739        extra: &[(String, String)],
740    ) -> Result<reqwest::Response, HttpClientError> {
741        let mut builder = self
742            .inner
743            .request(method_of(verb), &self.config.endpoint)
744            .header(reqwest::header::ACCEPT, ACCEPT)
745            .header(reqwest::header::CONTENT_TYPE, "application/json")
746            .body(body.to_vec());
747        let protocol = self
748            .protocol
749            .lock()
750            .unwrap_or_else(std::sync::PoisonError::into_inner)
751            .clone();
752        if let Some(protocol) = protocol
753            && !extra
754                .iter()
755                .any(|(name, _)| name.eq_ignore_ascii_case(PROTOCOL_HEADER))
756        {
757            builder = builder.header(PROTOCOL_HEADER, protocol);
758        }
759        for (name, value) in extra {
760            builder = builder.header(name.as_str(), value.as_str());
761        }
762        if let Some(id) = self.session_token() {
763            builder = builder.header(SESSION_HEADER, id.as_ref());
764        } else if require_session {
765            return Err(HttpClientError::NoSessionYet);
766        }
767        if let Some(Auth(auth)) = &self.auth
768            && let Some(token) = auth.bearer()
769        {
770            builder = builder.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
771        }
772        let response = builder.send().await.map_err(|error| HttpClientError::Io {
773            method: verb,
774            error,
775        })?;
776        self.absorb_session(&response)?;
777        Ok(response)
778    }
779
780    /// The `GET` half of [`HttpClient::send`], which also carries the cursor.
781    async fn get(
782        &self,
783        cursor: Option<EventId>,
784        require_session: bool,
785    ) -> Result<reqwest::Response, HttpClientError> {
786        let mut builder = self
787            .inner
788            .get(&self.config.endpoint)
789            .header(reqwest::header::ACCEPT, ACCEPT);
790        if let Some(id) = self.session_token() {
791            builder = builder.header(SESSION_HEADER, id.as_ref());
792        } else if require_session {
793            return Err(HttpClientError::NoSessionYet);
794        }
795        if let Some(id) = cursor {
796            builder = builder.header(LAST_EVENT_ID, cursor_value(id)?);
797        }
798        let response = builder
799            .send()
800            .await
801            .map_err(|error| HttpClientError::Io { method: GET, error })?;
802        self.absorb_session(&response)?;
803        if response.status().is_success() {
804            Ok(response)
805        } else {
806            Err(refusal(GET, response).await)
807        }
808    }
809
810    /// Records the negotiated revision from an `initialize` result, so later
811    /// requests carry it in `MCP-Protocol-Version`.
812    ///
813    /// Only from `2025-06-18`, the revision that defined the header; an older
814    /// session has no such header to send.
815    fn remember_protocol(&self, result: &JsonObject) {
816        let Ok(parsed) = serde_json::from_str::<Value>(result.as_str()) else {
817            return;
818        };
819        let Some(version) = parsed
820            .get("protocolVersion")
821            .and_then(Value::as_str)
822            .and_then(|text| text.parse::<crate::domain::ProtocolVersion>().ok())
823        else {
824            return;
825        };
826        if version >= crate::domain::ProtocolVersion::V2025_06_18 {
827            *self
828                .protocol
829                .lock()
830                .unwrap_or_else(std::sync::PoisonError::into_inner) =
831                Some(version.as_wire().into_owned());
832        }
833    }
834
835    /// Reads a `Mcp-Session-Id` off a response and remembers it.
836    ///
837    /// An unparseable id is an error rather than something to drop. A client
838    /// that ignores it sends its next request with no session header, and the
839    /// failure that follows points at the session layer instead of at the
840    /// header that was actually wrong.
841    fn absorb_session(&self, response: &reqwest::Response) -> Result<(), HttpClientError> {
842        let Some(raw) = response.headers().get(SESSION_HEADER) else {
843            return Ok(());
844        };
845        // Lossy on purpose: the replacement character is not a hex digit, so a
846        // header that is not even text fails `from_str` with the same typed
847        // error a well-formed-but-wrong header does. One way for this to go
848        // wrong, rather than two.
849        let text = String::from_utf8_lossy(raw.as_bytes());
850        let id = McpSessionIdError::validate_opaque(&text)
851            .map_err(|source| HttpClientError::SessionId { source })?;
852        *self
853            .session
854            .lock()
855            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::from(id));
856        Ok(())
857    }
858
859    /// Decides which of the two readable shapes a `2xx` arrived in.
860    fn shape(response: &reqwest::Response) -> Result<Shape, HttpClientError> {
861        let found = media_type(response);
862        if is_event_stream(found.as_deref()) {
863            return Ok(Shape::Stream);
864        }
865        if is_json(found.as_deref()) {
866            return Ok(Shape::Json);
867        }
868        Err(media_error(response))
869    }
870
871    /// Turns a `2xx` into a fully-read document.
872    /// Reads a streamed answer, holding everything that was not the reply.
873    ///
874    /// The stream is a sequence of messages and the request has one id, so
875    /// this reads until it sees that id. A request the server sends along the
876    /// way is answered when a handler is wired, and parked when it is not; any
877    /// other frame is parked. A frame that is not a JSON-RPC object at all is
878    /// dropped rather than held: there is no shape downstream could forward it
879    /// in.
880    ///
881    /// ## When the stream closes first
882    ///
883    /// `2025-11-25` (SEP-1699) lets a server send a priming event — an `id`
884    /// and a `retry`, no data — and then close the stream before it has the
885    /// answer, expecting the client to come back. So a stream that ends with
886    /// the answer still owed is resumed: wait the `retry` the server asked for,
887    /// `GET` with the last `Last-Event-ID`, and keep reading. A stream that
888    /// ended with no id to resume from is a transport fault, as before, and
889    /// resuming is bounded so a server that never answers cannot hold the
890    /// client forever.
891    async fn read_streamed(
892        &self,
893        response: reqwest::Response,
894        wants: Option<&Value>,
895    ) -> Result<Map<String, Value>, HttpClientError> {
896        let mut stream = WireStream::new(response);
897        let mut last_id: Option<String> = None;
898        let mut retry = DEFAULT_RETRY;
899        let mut resumes = 0_u32;
900        loop {
901            while let Some(event) = stream.next().await? {
902                if let Some(id) = event.id {
903                    last_id = Some(id);
904                }
905                if let Some(interval) = event.retry {
906                    retry = interval;
907                }
908                let Some(data) = event.data else {
909                    continue;
910                };
911                let Ok(Value::Object(candidate)) = serde_json::from_str(&data) else {
912                    continue;
913                };
914                if candidate.get("id") == wants && !is_request(&candidate) {
915                    return Ok(candidate);
916                }
917                if is_request(&candidate)
918                    && let Some(Handler(handler)) = &self.handler
919                {
920                    self.answer_request(handler.as_ref(), &candidate).await?;
921                    continue;
922                }
923                self.unsolicited.hold(candidate);
924            }
925            // No answer owed: this was a notification, so the peer said `202`
926            // and nothing is owed back.
927            if wants.is_none() {
928                return Ok(Map::new());
929            }
930            let Some(id) = last_id.clone() else {
931                return Err(HttpClientError::Unanswered { method: POST });
932            };
933            if resumes >= MAX_RESUMES {
934                return Err(HttpClientError::Unanswered { method: POST });
935            }
936            resumes = resumes.saturating_add(1);
937            tokio::time::sleep(retry).await;
938            let reopened = self.resume(&id).await?;
939            stream = WireStream::new(reopened);
940        }
941    }
942
943    /// Answers one request the server sent, over a `POST` of its own.
944    ///
945    /// The reply is a response, not a request, so the server acknowledges it
946    /// with `202` and nothing more is read.
947    async fn answer_request(
948        &self,
949        handler: &dyn ServerRequestHandler,
950        request: &Map<String, Value>,
951    ) -> Result<(), HttpClientError> {
952        let method = request
953            .get("method")
954            .and_then(Value::as_str)
955            .unwrap_or_default();
956        let params = JsonObject::from_value(request.get("params").cloned().unwrap_or_default())
957            .unwrap_or_default();
958        let reply = match handler.handle(method, &params).await {
959            Ok(result) => serde_json::json!({
960                "jsonrpc": "2.0",
961                "id": request.get("id").cloned().unwrap_or(Value::Null),
962                "result": serde_json::from_str::<Value>(result.as_str()).unwrap_or_default(),
963            }),
964            Err(refusal) => serde_json::json!({
965                "jsonrpc": "2.0",
966                "id": request.get("id").cloned().unwrap_or(Value::Null),
967                "error": { "code": refusal.code, "message": refusal.message },
968            }),
969        };
970        let body = serde_json::to_vec(&reply).map_err(|error| HttpClientError::Body {
971            method: "POST",
972            what: "encoded",
973            detail: error.to_string(),
974        })?;
975        self.send(POST, &body, false).await?;
976        Ok(())
977    }
978
979    /// Reopens the stream a `POST` was answered on, from `last_event_id`.
980    ///
981    /// The id is sent verbatim: the standard calls it opaque, and the server
982    /// that issued it is the only thing that can read it.
983    async fn resume(&self, last_event_id: &str) -> Result<reqwest::Response, HttpClientError> {
984        self.get_stream(Some(last_event_id)).await
985    }
986
987    /// Opens the standalone stream, or resumes it from `last_event_id`.
988    async fn get_stream(
989        &self,
990        last_event_id: Option<&str>,
991    ) -> Result<reqwest::Response, HttpClientError> {
992        let mut builder = self
993            .inner
994            .get(&self.config.endpoint)
995            .header(reqwest::header::ACCEPT, ACCEPT);
996        if let Some(id) = last_event_id {
997            builder = builder.header(LAST_EVENT_ID, id);
998        }
999        if let Some(Auth(auth)) = &self.auth
1000            && let Some(token) = auth.bearer()
1001        {
1002            builder = builder.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
1003        }
1004        if let Some(id) = self.session_token() {
1005            builder = builder.header(SESSION_HEADER, id.as_ref());
1006        }
1007        let protocol = self
1008            .protocol
1009            .lock()
1010            .unwrap_or_else(std::sync::PoisonError::into_inner)
1011            .clone();
1012        if let Some(protocol) = protocol {
1013            builder = builder.header(PROTOCOL_HEADER, protocol);
1014        }
1015        let response = builder
1016            .send()
1017            .await
1018            .map_err(|error| HttpClientError::Io { method: GET, error })?;
1019        self.absorb_session(&response)?;
1020        if response.status().is_success() {
1021            Ok(response)
1022        } else {
1023            Err(refusal(GET, response).await)
1024        }
1025    }
1026
1027    /// Opens the session's standing stream and reads it until the peer ends it.
1028    ///
1029    /// ## Why a client that only posts needs this
1030    ///
1031    /// A Streamable HTTP server may push at any time — `list_changed`, a
1032    /// sampling request, a log message — on the stream a `GET` opens. A client
1033    /// that only ever POSTs never opens one, so those messages have nowhere to
1034    /// arrive, and a bridge forwards every request while telling its own client
1035    /// nothing. Opening the stream is what makes [`Self::take_pending`] mean
1036    /// anything.
1037    ///
1038    /// The read runs in its own task and parks into the same queue a mid-call
1039    /// frame lands in, so a caller cannot tell which of the two produced what,
1040    /// and one wait covers both.
1041    ///
1042    /// # Errors
1043    ///
1044    /// [`HttpClientError::NoSessionYet`] before a session exists — there is no
1045    /// stream to open without one — and any error a `GET` can produce. Failing
1046    /// to open is reported rather than swallowed.
1047    pub async fn listen(&self) -> Result<(), HttpClientError> {
1048        let mut stream = self.open_stream(None, true).await?;
1049        let parked = Arc::clone(&self.unsolicited);
1050        tokio::spawn(async move {
1051            // Ends when the peer closes the stream. No reconnect here:
1052            // `resumable` is the read that survives a drop, and re-establishing
1053            // a stream is a different decision with its own rules.
1054            while let Ok(Some(event)) = stream.next_event().await {
1055                // A frame that is not an object is not a JSON-RPC message and
1056                // has no shape to forward it in.
1057                if let Ok(Value::Object(frame)) = serde_json::from_str(event.data()) {
1058                    parked.hold(frame);
1059                }
1060            }
1061        });
1062        Ok(())
1063    }
1064
1065    /// Opens the standalone `GET` stream in the background and keeps it open.
1066    ///
1067    /// Through `2025-11-25` a server may send a request of its own — sampling,
1068    /// elicitation, roots — and may send it on this stream rather than on the
1069    /// response to a call, since the call that needed the answer has no stream
1070    /// of its own to put it on. A client that declared one of those
1071    /// capabilities and never opens the stream is never asked, and a server
1072    /// whose tool waits for the answer waits forever.
1073    ///
1074    /// Requests are answered through the wired handler; everything else is
1075    /// parked for [`take_pending`](Self::take_pending). A stream the server
1076    /// ends is reopened after its `retry` interval with the last event id, and
1077    /// a server that answers `405` to the `GET` — the specification lets it —
1078    /// ends the task quietly. The task holds only a weak reference, so
1079    /// dropping the client stops it.
1080    pub fn spawn_listener(self: &Arc<Self>) {
1081        let weak = Arc::downgrade(self);
1082        tokio::spawn(async move {
1083            let mut last_id: Option<String> = None;
1084            let mut retry = DEFAULT_RETRY;
1085            let mut failures = 0_u32;
1086            loop {
1087                let Some(client) = weak.upgrade() else {
1088                    return;
1089                };
1090                let response = client.get_stream(last_id.as_deref()).await;
1091                drop(client);
1092                let response = match response {
1093                    Ok(response) => response,
1094                    // The specification lets a server decline the standalone
1095                    // stream, and `405` is how it says so. Nothing to retry.
1096                    Err(HttpClientError::Refused { status, .. })
1097                        if status == reqwest::StatusCode::METHOD_NOT_ALLOWED =>
1098                    {
1099                        return;
1100                    }
1101                    Err(_) => {
1102                        failures = failures.saturating_add(1);
1103                        if failures >= MAX_RESUMES {
1104                            return;
1105                        }
1106                        tokio::time::sleep(retry).await;
1107                        continue;
1108                    }
1109                };
1110                failures = 0;
1111                let mut stream = WireStream::new(response);
1112                while let Ok(Some(event)) = stream.next().await {
1113                    if let Some(id) = event.id {
1114                        last_id = Some(id);
1115                    }
1116                    if let Some(interval) = event.retry {
1117                        retry = interval;
1118                    }
1119                    let Some(data) = event.data else {
1120                        continue;
1121                    };
1122                    let Ok(Value::Object(frame)) = serde_json::from_str(&data) else {
1123                        continue;
1124                    };
1125                    let Some(client) = weak.upgrade() else {
1126                        return;
1127                    };
1128                    match &client.handler {
1129                        Some(Handler(handler)) if is_request(&frame) => {
1130                            let _ = client.answer_request(handler.as_ref(), &frame).await;
1131                        }
1132                        _ => client.unsolicited.hold(frame),
1133                    }
1134                }
1135                tokio::time::sleep(retry).await;
1136            }
1137        });
1138    }
1139
1140    /// Takes every frame a server volunteered while this client was busy.
1141    #[must_use]
1142    pub fn take_pending(&self) -> Vec<Map<String, Value>> {
1143        self.unsolicited.take()
1144    }
1145
1146    async fn read_document(
1147        &self,
1148        response: reqwest::Response,
1149        method: &'static str,
1150    ) -> Result<HttpResponse, HttpClientError> {
1151        let shape = Self::shape(&response)?;
1152        if shape == Shape::Stream {
1153            // Refused here, and only here. `read_document` is the caller that
1154            // wanted a whole JSON document and would otherwise hand a caller
1155            // SSE bytes under a JSON contract. Every other caller that can meet
1156            // a stream reads it as one; routing a call through here instead is
1157            // how a server that prefers `text/event-stream` — the preference
1158            // the specification states — ends up failing a handshake on its
1159            // closing notification.
1160            return Err(media_error(&response));
1161        }
1162        let content_type = media_type(&response);
1163        let status = response.status();
1164        let body = response
1165            .bytes()
1166            .await
1167            .map_err(|error| HttpClientError::Io { method, error })?;
1168        Ok(HttpResponse {
1169            status,
1170            content_type,
1171            body: body.to_vec(),
1172            session: self.session_id(),
1173        })
1174    }
1175}
1176
1177/// The two readable response shapes.
1178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1179enum Shape {
1180    /// One JSON document; the body is now closed.
1181    Json,
1182    /// An open event stream.
1183    Stream,
1184}
1185
1186/// An open event stream, read one whole frame at a time.
1187pub struct HttpStream {
1188    response: reqwest::Response,
1189    reader: FrameReader,
1190    /// Frames already assembled out of a previous read, waiting their turn.
1191    ///
1192    /// A read is not a frame. One TCP segment routinely carries several
1193    /// complete events, and taking only the first would silently drop the rest
1194    /// — a stream that is short by exactly the number of events a fast peer
1195    /// happened to batch.
1196    ready: VecDeque<SseEvent>,
1197    delivered: u64,
1198    last: Option<EventId>,
1199}
1200
1201impl fmt::Debug for HttpStream {
1202    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1203        formatter
1204            .debug_struct("HttpStream")
1205            .field("delivered", &self.delivered)
1206            .field("last", &self.last)
1207            .field("buffered", &self.reader.buffered())
1208            .finish_non_exhaustive()
1209    }
1210}
1211
1212impl HttpStream {
1213    const fn new(response: reqwest::Response) -> Self {
1214        Self {
1215            response,
1216            reader: FrameReader::new(),
1217            ready: VecDeque::new(),
1218            delivered: 0,
1219            last: None,
1220        }
1221    }
1222
1223    /// The next complete frame, or `None` once the peer has finished.
1224    ///
1225    /// # Errors
1226    ///
1227    /// [`HttpClientError::Frame`] for a terminated block that does not parse,
1228    /// and [`HttpClientError::Io`] for a transport failure mid-body.
1229    ///
1230    /// `None` is not the same as a failure. A body that ended **on a block
1231    /// boundary** is the peer saying there was nothing more; a body that ended
1232    /// with bytes still buffered is a connection that dropped mid-event. The
1233    /// first ends the stream, the second is a resume — which is why
1234    /// [`HttpStream::has_partial`] exists and why this returns `Option` rather
1235    /// than erroring on the end.
1236    pub async fn next_event(&mut self) -> Result<Option<SseEvent>, HttpClientError> {
1237        loop {
1238            // Whatever a previous read already assembled is handed over before
1239            // the socket is touched again. Reading first would reorder the
1240            // stream past its own buffer, which is a bug that only shows up
1241            // when a peer is fast.
1242            if let Some(event) = self.ready.pop_front() {
1243                return Ok(Some(self.record(event)));
1244            }
1245            let chunk = self
1246                .response
1247                .chunk()
1248                .await
1249                .map_err(|error| HttpClientError::Io { method: GET, error })?;
1250            let Some(chunk) = chunk else {
1251                return Ok(None);
1252            };
1253            self.ready.extend(self.reader.push(&chunk)?);
1254        }
1255    }
1256
1257    /// Notes a delivered event and its id, so the resume knows where to start.
1258    const fn record(&mut self, event: SseEvent) -> SseEvent {
1259        self.delivered = self.delivered.saturating_add(1);
1260        if let Some(id) = event.id() {
1261            self.last = Some(id);
1262        }
1263        event
1264    }
1265
1266    /// How many events this stream has delivered.
1267    #[must_use]
1268    pub const fn delivered(&self) -> u64 {
1269        self.delivered
1270    }
1271
1272    /// The id of the last event delivered, which is what a resume asks for.
1273    #[must_use]
1274    pub const fn last_event_id(&self) -> Option<EventId> {
1275        self.last
1276    }
1277
1278    /// The id of the last event handed to the caller, or `None` if none has
1279    /// carried an id.
1280    ///
1281    /// A resume starts here, so a caller driving this stream itself — rather
1282    /// than through [`ResumableStream`], which tracks it privately — has no
1283    /// other way to learn it. Without this the only way to resume is to give
1284    /// up the cursor the client already knows and guess.
1285    #[must_use]
1286    pub const fn last_delivered(&self) -> Option<EventId> {
1287        self.last
1288    }
1289
1290    /// Whether bytes of an unfinished event are still held.
1291    #[must_use]
1292    pub const fn has_partial(&self) -> bool {
1293        self.reader.buffered() > 0
1294    }
1295}
1296
1297/// A read that reconnects itself, so a dropped stream costs a round trip rather
1298/// than the session.
1299#[derive(Debug)]
1300pub struct ResumableStream<'client> {
1301    client: &'client HttpClient,
1302    current: Option<HttpStream>,
1303    cursor: Option<EventId>,
1304    delivered: u64,
1305    attempts: u32,
1306    require_session: bool,
1307    policy: Resumable,
1308    finished: bool,
1309}
1310
1311impl ResumableStream<'_> {
1312    /// The next event, across as many reconnects as the budget allows.
1313    ///
1314    /// # Errors
1315    ///
1316    /// [`HttpClientError::ResumeExhausted`] once the reconnect budget is spent,
1317    /// carrying how far the read got and why the last attempt ended. A
1318    /// non-transient failure — a malformed frame, a media type this client
1319    /// cannot read, an unparseable session id — is returned as itself, because
1320    /// reconnecting would produce the same one.
1321    pub async fn next_event(&mut self) -> Result<Option<SseEvent>, HttpClientError> {
1322        loop {
1323            if self.finished {
1324                return Ok(None);
1325            }
1326            // Taken out rather than borrowed in place: the loop body reassigns
1327            // `current` on a reconnect, and holding a borrow across that is the
1328            // borrow checker telling the truth about the state machine.
1329            let mut stream = loop {
1330                if let Some(stream) = self.current.take() {
1331                    break stream;
1332                }
1333                // A connection that cannot be *established* is the same event
1334                // as a stream that breaks — a peer that is not answering — and
1335                // has to be spent against the same budget. Treating only the
1336                // second as a reconnect means a peer that refuses the first
1337                // request outright ends the read with a bare `Io`, and the
1338                // caller cannot tell it apart from a peer that answered and
1339                // then vanished.
1340                match self
1341                    .client
1342                    .open_stream(self.cursor, self.require_session)
1343                    .await
1344                {
1345                    Ok(opened) => self.current = Some(opened),
1346                    Err(error) if error.is_transient() => {
1347                        self.attempts = self.attempts.saturating_add(1);
1348                        if self.attempts > self.policy.budget.attempts() {
1349                            return Err(self.exhausted(error));
1350                        }
1351                    }
1352                    Err(error) => return Err(self.widen(error)),
1353                }
1354            };
1355
1356            match stream.next_event().await {
1357                Ok(Some(event)) => {
1358                    self.delivered = self.delivered.saturating_add(1);
1359                    // Read once, into a name. The id decides both where a
1360                    // resume would start and whether one is possible at all,
1361                    // and asking twice invites the two answers to disagree.
1362                    if let Some(id) = event.id() {
1363                        self.cursor = Some(id);
1364                    } else {
1365                        // No id means no cursor, and a peer asked to resume from
1366                        // the previous id would repeat this event. The read is
1367                        // therefore finished here, and saying so is the only
1368                        // honest answer: a resume that guesses is a duplicate.
1369                        self.finished = true;
1370                        self.current = None;
1371                        return Ok(Some(event));
1372                    }
1373                    self.current = Some(stream);
1374                    return Ok(Some(event));
1375                }
1376                Ok(None) if stream.has_partial() => {
1377                    // Ended mid-event. Reconnect from the last delivered id.
1378                    self.attempts = self.attempts.saturating_add(1);
1379                    if self.attempts > self.policy.budget.attempts() {
1380                        return Err(self.exhausted(HttpClientError::StreamEnded {
1381                            delivered: self.delivered,
1382                        }));
1383                    }
1384                }
1385                Ok(None) => {
1386                    // Ended on a boundary: the peer finished. Reconnecting
1387                    // would ask a peer that has said it is done to say it again.
1388                    self.finished = true;
1389                    self.current = None;
1390                    return Ok(None);
1391                }
1392                Err(error) if error.is_transient() => {
1393                    self.attempts = self.attempts.saturating_add(1);
1394                    if self.attempts > self.policy.budget.attempts() {
1395                        return Err(self.exhausted(error));
1396                    }
1397                }
1398                Err(error) => return Err(self.widen(error)),
1399            }
1400        }
1401    }
1402
1403    /// How many events this read has delivered, across every connection.
1404    #[must_use]
1405    pub const fn delivered(&self) -> u64 {
1406        self.delivered
1407    }
1408
1409    /// The id a reconnect would ask for.
1410    #[must_use]
1411    pub const fn cursor(&self) -> Option<EventId> {
1412        self.cursor
1413    }
1414
1415    /// How many reconnects have been used.
1416    #[must_use]
1417    pub const fn reconnects(&self) -> u32 {
1418        self.attempts
1419    }
1420
1421    /// Wraps a failure that happened after a reconnect, so the caller can see
1422    /// it had already lost the stream once.
1423    fn widen(&self, error: HttpClientError) -> HttpClientError {
1424        if self.attempts == 0 {
1425            error
1426        } else {
1427            HttpClientError::ResumeExhausted {
1428                attempts: self.attempts,
1429                delivered: self.delivered,
1430                source: Box::new(error),
1431            }
1432        }
1433    }
1434
1435    fn exhausted(&self, source: HttpClientError) -> HttpClientError {
1436        HttpClientError::ResumeExhausted {
1437            attempts: self.attempts.saturating_sub(1),
1438            delivered: self.delivered,
1439            source: Box::new(source),
1440        }
1441    }
1442}
1443
1444/// A response that is either one document or an open stream.
1445#[derive(Debug)]
1446pub enum HttpReply {
1447    /// The peer answered with a single JSON document.
1448    Document(HttpResponse),
1449    /// The peer answered with an open event stream.
1450    Stream(HttpStream),
1451}
1452
1453/// A fully-read response.
1454#[derive(Debug, Clone)]
1455pub struct HttpResponse {
1456    status: reqwest::StatusCode,
1457    content_type: Option<String>,
1458    body: Vec<u8>,
1459    session: Option<McpSessionId>,
1460}
1461
1462impl HttpResponse {
1463    /// The status the peer answered with, always `2xx` — a refusal is an error.
1464    #[must_use]
1465    pub const fn status(&self) -> reqwest::StatusCode {
1466        self.status
1467    }
1468
1469    /// The `Content-Type`, with its parameters dropped, or `None` if the peer
1470    /// sent none.
1471    #[must_use]
1472    pub fn content_type(&self) -> Option<&str> {
1473        self.content_type.as_deref()
1474    }
1475
1476    /// The body, read in full.
1477    #[must_use]
1478    pub fn body(&self) -> &[u8] {
1479        &self.body
1480    }
1481
1482    /// The session in force when this response arrived.
1483    #[must_use]
1484    pub const fn session_id(&self) -> Option<McpSessionId> {
1485        self.session
1486    }
1487}
1488
1489/// The `Content-Type` with its parameters dropped and its case lowered.
1490///
1491/// `text/event-stream; charset=utf-8` is still `text/event-stream`, and a
1492/// comparison against the whole header value fails the first time a peer adds a
1493/// parameter. A parameter is ordinary, not hostile.
1494fn media_type(response: &reqwest::Response) -> Option<String> {
1495    let raw = response
1496        .headers()
1497        .get(reqwest::header::CONTENT_TYPE)?
1498        .to_str()
1499        .ok()?;
1500    Some(
1501        raw.split(';')
1502            .next()
1503            .unwrap_or(raw)
1504            .trim()
1505            .to_ascii_lowercase(),
1506    )
1507}
1508
1509/// Whether a media type is JSON, accepting the `+json` structured suffix.
1510fn is_json(content_type: Option<&str>) -> bool {
1511    content_type.is_some_and(|value| value == JSON || value.ends_with("+json"))
1512}
1513
1514/// Whether a media type is an event stream.
1515fn is_event_stream(content_type: Option<&str>) -> bool {
1516    content_type == Some(EVENT_STREAM)
1517}
1518
1519/// The error for a `2xx` whose body this call cannot read.
1520///
1521/// Reads the media type once more rather than taking it, so the call sites that
1522/// already have it do not have to hand it over and this cannot disagree with
1523/// them about what the peer sent.
1524fn media_error(response: &reqwest::Response) -> HttpClientError {
1525    media_type(response).map_or(HttpClientError::MissingMediaType, |got| {
1526        HttpClientError::MediaType { got }
1527    })
1528}
1529
1530/// Builds the error for a non-2xx, reading a bounded preview of the body.
1531async fn refusal(method: &'static str, response: reqwest::Response) -> HttpClientError {
1532    let status = response.status();
1533    let content_type = media_type(&response);
1534    let preview = response
1535        .bytes()
1536        .await
1537        .map_or_else(|_| preview(&[]), |body| preview(&body));
1538    HttpClientError::Refused {
1539        method,
1540        status,
1541        content_type,
1542        preview,
1543    }
1544}
1545
1546/// Renders a cursor as a header value, or explains why it cannot be one.
1547///
1548/// # Errors
1549///
1550/// [`SseError::MalformedId`] if the rendered id is not something a header can
1551/// carry. Reported as the codec's own error because a cursor that will not fit
1552/// in a header is a cursor the peer could not have issued either.
1553fn cursor_value(id: EventId) -> Result<reqwest::header::HeaderValue, HttpClientError> {
1554    let text = id.to_string();
1555    if text.len() > MAX_CURSOR {
1556        return Err(HttpClientError::Frame {
1557            source: SseError::MalformedId,
1558        });
1559    }
1560    reqwest::header::HeaderValue::from_str(&text).map_err(|_| HttpClientError::Frame {
1561        source: SseError::MalformedId,
1562    })
1563}
1564
1565/// The `Method` a verb names.
1566///
1567/// A `&'static str` is carried through this module so an error can report the
1568/// verb it was working on, and `Method` is not constructible from one in a
1569/// `const` context.
1570///
1571/// The fallthrough is `GET` because `GET` is the only arm here that is also the
1572/// zero value a caller can reach without asking: a verb this module did not
1573/// write cannot produce a request, because the three call sites that pass one
1574/// pass a constant from this same file.
1575const fn method_of(verb: &str) -> reqwest::Method {
1576    match verb.as_bytes() {
1577        b"POST" => reqwest::Method::POST,
1578        b"DELETE" => reqwest::Method::DELETE,
1579        _ => reqwest::Method::GET,
1580    }
1581}
1582
1583impl ClientTransport for HttpClient {
1584    /// POSTs a request frame and returns the `result` it carries.
1585    ///
1586    /// The id matters here for the same reason it matters on stdio: a reply
1587    /// that arrives for a different request must be recognisable as one, and
1588    /// this transport carries a session's worth of traffic over one connection.
1589    ///
1590    /// # Errors
1591    ///
1592    /// [`TransportError::Unavailable`] naming the refusal — a frame this client
1593    /// could not assemble, a status the transport rejects, or a reply whose
1594    /// `error` member names a failure.
1595    fn send(
1596        &self,
1597        method: &str,
1598        params: &JsonObject,
1599    ) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>> {
1600        // Assembled before the future is built, because the port elides the
1601        // return lifetime to `&self` and a boxed future cannot borrow the
1602        // caller's `method` or `params`.
1603        let prepared = (|| {
1604            let id = self.next_request_id();
1605            let name = MethodName::new(method).map_err(unavailable)?;
1606            let document = request_document(&id, &name, params).map_err(unavailable)?;
1607            let envelope = document.as_object().cloned().ok_or_else(|| {
1608                unavailable(HttpClientError::NotAMessage {
1609                    got: document.to_string(),
1610                })
1611            })?;
1612            Ok::<_, TransportError>((id, envelope))
1613        })();
1614
1615        let initializing = method == "initialize";
1616        if initializing {
1617            // A new handshake negotiates afresh: the revision a previous one
1618            // settled on does not describe this request, and sending it would
1619            // have the server compare it with a session it is about to replace.
1620            *self
1621                .protocol
1622                .lock()
1623                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1624        }
1625        Box::pin(async move {
1626            let (id, envelope) = prepared?;
1627            // `post_envelope` rather than `post`, and that choice is the whole
1628            // of this method. `post` insists on a whole JSON document, so a
1629            // server that answers with an event stream — the shape the
1630            // specification prefers, and the one this crate's own server uses —
1631            // was rejected outright, and the handshake failed before it began.
1632            // Going through the envelope reader also parks anything the server
1633            // volunteered on the way, which a client that only read its own
1634            // answer had nowhere to put.
1635            let reply = self
1636                .post_envelope(&envelope)
1637                .await
1638                .map_err(unavailable)?
1639                .ok_or_else(|| unavailable("a request was answered with no body at all"))?;
1640            // One re-serialisation to reach the shared `reply_result`, which
1641            // speaks strings. Taken deliberately over hand-matching the id and
1642            // lifting `result` here: a second implementation of that rule is a
1643            // second answer to when a reply counts as this request's.
1644            let text = serde_json::to_string(&Value::Object(reply)).map_err(unavailable)?;
1645            let result = reply_result(&text, &id).map_err(unavailable)?;
1646            if initializing {
1647                self.remember_protocol(&result);
1648            }
1649            Ok(result)
1650        })
1651    }
1652
1653    /// POSTs a notification frame and returns without reading a body.
1654    ///
1655    /// A notification is answered with a `202` and no content, so there is no
1656    /// `result` to read and no id to match — asking for one would turn every
1657    /// correctly-answered notification into a malformed-frame error.
1658    ///
1659    /// # Errors
1660    ///
1661    /// [`TransportError::Unavailable`] if the frame cannot be assembled or the
1662    /// `POST` does not succeed.
1663    fn notify(
1664        &self,
1665        method: &str,
1666        params: &JsonObject,
1667    ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>> {
1668        let prepared = (|| {
1669            let name = MethodName::new(method).map_err(unavailable)?;
1670            let document = notification_document(&name, params).map_err(unavailable)?;
1671            let Some(envelope) = document.as_object().cloned() else {
1672                return Err(unavailable(HttpClientError::NotAMessage {
1673                    got: document.to_string(),
1674                }));
1675            };
1676            Ok::<_, TransportError>(envelope)
1677        })();
1678
1679        Box::pin(async move {
1680            let document = prepared?;
1681            // `post_envelope` rather than `post`, and this is the notification
1682            // half of the same fix `send` needs. `post` reads through
1683            // `read_document`, which refuses an event stream — so a server
1684            // that answers the closing `notifications/initialized` with one,
1685            // which the specification allows it to do, failed the handshake on
1686            // its last step.
1687            //
1688            // The envelope's answer is discarded rather than matched: a
1689            // notification has no id, and the specification answers one with
1690            // `202` and nothing worth reading. What matters is that the peer
1691            // accepted it. A frame the server volunteered on the way back is
1692            // not discarded — `post_envelope` holds it, which is the only
1693            // reason a client can learn of a push at all.
1694            self.post_envelope(&document).await.map_err(unavailable)?;
1695            Ok(())
1696        })
1697    }
1698}
1699
1700/// This transport's failures, as the port's error.
1701fn unavailable(error: impl core::fmt::Display) -> TransportError {
1702    TransportError::Unavailable(error.to_string())
1703}