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, ¶ms).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}