Skip to main content

agent_sdk/mcp/
http.rs

1//! Streamable-HTTP (and SSE) MCP transport.
2//!
3//! Implements the MCP "Streamable HTTP" transport introduced in revision
4//! `2025-03-26` and carried forward in later revisions. A single HTTP endpoint
5//! serves every JSON-RPC message:
6//!
7//! * The client `POST`s a JSON-RPC request (or notification) to the endpoint.
8//! * The server replies with either a single `application/json` body (one
9//!   JSON-RPC message) or a `text/event-stream` body (Server-Sent Events, each
10//!   `data:` line carrying one JSON-RPC message). Either way, this transport
11//!   resolves the [`JsonRpcResponse`] whose `id` matches the request it sent.
12//! * The server may issue a `Mcp-Session-Id` header on the `initialize`
13//!   response; the client echoes it on all subsequent requests.
14//! * After initialization the client sends the negotiated `MCP-Protocol-Version`
15//!   header on every request, as the spec mandates.
16//!
17//! Authentication is supplied as a bearer token / OAuth access token (sent as an
18//! `Authorization: Bearer …` header) or arbitrary custom headers.
19//!
20//! The transport is generic over the HTTP layer via [`HttpPoster`]: production
21//! code uses [`ReqwestPoster`] (the default), while tests inject a scripted
22//! poster to exercise the JSON and SSE response paths deterministically with no
23//! live network.
24
25use anyhow::{Context, Result, bail};
26use async_trait::async_trait;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::time::Duration;
30use tokio::sync::RwLock;
31
32use super::protocol::{JsonRpcRequest, JsonRpcResponse, RequestId};
33use super::transport::{McpTransport, notification_body};
34
35/// Header carrying the MCP session id assigned by the server.
36const SESSION_ID_HEADER: &str = "Mcp-Session-Id";
37/// Header carrying the negotiated MCP protocol revision.
38const PROTOCOL_VERSION_HEADER: &str = "MCP-Protocol-Version";
39
40/// Default request timeout for the reqwest client backing [`ReqwestPoster`].
41///
42/// Without this, a streamable-HTTP server that holds an SSE stream open (with
43/// keep-alive comments) would block a request forever. Matches the stdio
44/// transport's default response timeout. Override per poster with
45/// [`ReqwestPoster::with_timeout`].
46pub const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_mins(1);
47
48/// Default per-request send deadline for [`StreamableHttpTransport`] (60s).
49///
50/// Applied around each [`HttpPoster::post`] call, independent of the underlying
51/// client's own timeout, so `send`/`send_notification` always have a
52/// cancellation path. Override per transport with
53/// [`StreamableHttpTransport::with_request_timeout`] or
54/// [`StreamableHttpTransport::with_timeout`].
55pub const DEFAULT_SEND_DEADLINE: Duration = Duration::from_mins(1);
56
57/// Maximum response body the [`ReqwestPoster`] will buffer. An endless SSE
58/// stream would otherwise grow memory without bound.
59const MAX_RESPONSE_BODY_BYTES: usize = 16 * 1024 * 1024;
60
61/// A single HTTP response from an MCP endpoint, normalised across the two
62/// streamable-HTTP body shapes.
63#[derive(Clone, Debug)]
64pub struct HttpReply {
65    /// `Content-Type` of the response body (lower-cased, no parameters).
66    pub content_type: String,
67    /// Raw response body bytes.
68    pub body: String,
69    /// Value of the `Mcp-Session-Id` response header, if present.
70    pub session_id: Option<String>,
71}
72
73impl HttpReply {
74    /// Construct a JSON-body reply (`application/json`).
75    #[must_use]
76    pub fn json(body: impl Into<String>) -> Self {
77        Self {
78            content_type: "application/json".to_string(),
79            body: body.into(),
80            session_id: None,
81        }
82    }
83
84    /// Construct an SSE-body reply (`text/event-stream`).
85    #[must_use]
86    pub fn event_stream(body: impl Into<String>) -> Self {
87        Self {
88            content_type: "text/event-stream".to_string(),
89            body: body.into(),
90            session_id: None,
91        }
92    }
93
94    /// Attach a session id to this reply (as if returned in the header).
95    #[must_use]
96    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
97        self.session_id = Some(session_id.into());
98        self
99    }
100}
101
102/// The HTTP request a transport wants to make, in transport-neutral form.
103#[derive(Clone, Debug)]
104pub struct HttpRequest {
105    /// Serialized JSON-RPC body to POST.
106    pub body: String,
107    /// `Authorization` header value, if a token is configured.
108    pub authorization: Option<String>,
109    /// `Mcp-Session-Id` to echo, once one has been assigned.
110    pub session_id: Option<String>,
111    /// Negotiated `MCP-Protocol-Version`, once initialization has completed.
112    pub protocol_version: Option<String>,
113    /// Extra static headers configured on the transport.
114    pub extra_headers: Vec<(String, String)>,
115}
116
117/// Abstraction over the act of `POST`ing one JSON-RPC message to the MCP endpoint.
118///
119/// Production uses [`ReqwestPoster`]; tests inject a scripted poster so the
120/// JSON / SSE decode paths run with zero live network.
121#[async_trait]
122pub trait HttpPoster: Send + Sync {
123    /// POST `request` to the MCP endpoint and return the normalised reply.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the HTTP request fails or the server returns a
128    /// non-success status.
129    async fn post(&self, request: HttpRequest) -> Result<HttpReply>;
130
131    /// The poster's own per-request timeout, when it knows it.
132    ///
133    /// [`StreamableHttpTransport::with_request_timeout`] uses this to warn
134    /// when a caller raises the send deadline past the poster's internal
135    /// timeout (the raise would silently never take effect). Default `None`
136    /// means "unknown" and disables the check.
137    fn timeout_hint(&self) -> Option<Duration> {
138        None
139    }
140}
141
142/// Authentication strategy for an HTTP MCP connection.
143#[derive(Clone, Debug, Default)]
144pub enum McpAuth {
145    /// No authentication.
146    #[default]
147    None,
148    /// Static bearer token / OAuth access token sent as `Authorization: Bearer`.
149    Bearer(String),
150}
151
152impl McpAuth {
153    /// Render the `Authorization` header value, if any.
154    #[must_use]
155    fn header_value(&self) -> Option<String> {
156        match self {
157            Self::None => None,
158            Self::Bearer(token) => Some(format!("Bearer {token}")),
159        }
160    }
161}
162
163/// Streamable-HTTP MCP transport.
164///
165/// Construct with [`StreamableHttpTransport::new`] for a live connection, or
166/// [`StreamableHttpTransport::with_poster`] to inject a custom [`HttpPoster`]
167/// (used by tests).
168pub struct StreamableHttpTransport {
169    poster: Arc<dyn HttpPoster>,
170    auth: McpAuth,
171    extra_headers: Vec<(String, String)>,
172    next_id: AtomicU64,
173    /// Session id assigned by the server on `initialize`, echoed thereafter.
174    session_id: RwLock<Option<String>>,
175    /// Protocol revision negotiated during `initialize`.
176    protocol_version: RwLock<Option<String>>,
177    /// Overall deadline applied around each [`HttpPoster::post`] call so a
178    /// slow, hung, or keep-alive SSE server can never wedge a turn. Defaults to
179    /// [`DEFAULT_SEND_DEADLINE`]; set via [`StreamableHttpTransport::with_request_timeout`].
180    send_deadline: Duration,
181}
182
183impl StreamableHttpTransport {
184    /// Create a transport that talks to `endpoint` over real HTTP.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if the underlying HTTP client cannot be built.
189    pub fn new(endpoint: impl Into<String>, auth: McpAuth) -> Result<Arc<Self>> {
190        Ok(Arc::new(Self::builder(endpoint, auth)?))
191    }
192
193    /// Create a transport over real HTTP with a custom per-request timeout.
194    ///
195    /// Sets *both* the underlying reqwest client's request timeout and the
196    /// transport-level send deadline to `request_timeout`, so a slow or hung
197    /// streamable-HTTP server trips this deadline instead of the
198    /// [`DEFAULT_SEND_DEADLINE`] / [`DEFAULT_HTTP_TIMEOUT`] defaults. MCP tool
199    /// calls routinely exceed 60s (builds, codegen); raise the timeout for
200    /// those servers, or lower it for latency-sensitive ones.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the underlying HTTP client cannot be built.
205    pub fn with_timeout(
206        endpoint: impl Into<String>,
207        auth: McpAuth,
208        request_timeout: Duration,
209    ) -> Result<Arc<Self>> {
210        Ok(Arc::new(Self::builder_with_timeout(
211            endpoint,
212            auth,
213            request_timeout,
214        )?))
215    }
216
217    /// Create a transport backed by a custom [`HttpPoster`].
218    ///
219    /// This is the seam tests use to script JSON / SSE responses without a
220    /// network.
221    #[must_use]
222    pub fn with_poster(poster: Arc<dyn HttpPoster>, auth: McpAuth) -> Arc<Self> {
223        Arc::new(Self::with_poster_owned(poster, auth))
224    }
225
226    /// Create an un-wrapped transport over real HTTP for further builder-style
227    /// configuration (e.g. [`StreamableHttpTransport::with_header`]).
228    ///
229    /// The backing reqwest client uses [`DEFAULT_HTTP_TIMEOUT`] and the
230    /// transport uses [`DEFAULT_SEND_DEADLINE`]. To *raise* the request timeout
231    /// past the default minute (e.g. for long builds / codegen), use
232    /// [`StreamableHttpTransport::builder_with_timeout`] — calling
233    /// [`StreamableHttpTransport::with_request_timeout`] on a builder produced
234    /// here only relaxes the send deadline and cannot lift the client's own
235    /// [`DEFAULT_HTTP_TIMEOUT`] (see its docs).
236    ///
237    /// Wrap the result in `Arc` before handing it to `McpClient::new`:
238    ///
239    /// ```no_run
240    /// use std::sync::Arc;
241    /// use agent_sdk::mcp::{McpAuth, StreamableHttpTransport};
242    ///
243    /// # fn main() -> anyhow::Result<()> {
244    /// let transport = Arc::new(
245    ///     StreamableHttpTransport::builder("https://example.com/mcp", McpAuth::None)?
246    ///         .with_header("X-Tenant-Id", "acme"),
247    /// );
248    /// # let _ = transport;
249    /// # Ok(())
250    /// # }
251    /// ```
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the underlying HTTP client cannot be built.
256    pub fn builder(endpoint: impl Into<String>, auth: McpAuth) -> Result<Self> {
257        let poster = ReqwestPoster::new(endpoint)?;
258        Ok(Self::with_poster_owned(Arc::new(poster), auth))
259    }
260
261    /// Create an un-wrapped transport over real HTTP with a custom request
262    /// timeout, for further builder-style configuration before wrapping in
263    /// `Arc`.
264    ///
265    /// Sets *both* the backing reqwest client's request timeout *and* the
266    /// transport-level send deadline to `request_timeout`. This is the path to
267    /// use when **raising** the timeout past [`DEFAULT_HTTP_TIMEOUT`]: building
268    /// the client with the higher timeout is the only way a long-running tool
269    /// call (build, codegen) can run past the default minute — chaining
270    /// [`StreamableHttpTransport::with_request_timeout`] onto a plain
271    /// [`StreamableHttpTransport::builder`] cannot, because the underlying
272    /// reqwest client was already built with [`DEFAULT_HTTP_TIMEOUT`].
273    ///
274    /// Mirrors [`StreamableHttpTransport::with_timeout`] but returns an
275    /// un-wrapped transport so callers can chain
276    /// [`StreamableHttpTransport::with_header`] before wrapping in `Arc`:
277    ///
278    /// ```no_run
279    /// use std::sync::Arc;
280    /// use std::time::Duration;
281    /// use agent_sdk::mcp::{McpAuth, StreamableHttpTransport};
282    ///
283    /// # fn main() -> anyhow::Result<()> {
284    /// let transport = Arc::new(
285    ///     StreamableHttpTransport::builder_with_timeout(
286    ///         "https://example.com/mcp",
287    ///         McpAuth::None,
288    ///         Duration::from_secs(300),
289    ///     )?
290    ///     .with_header("X-Tenant-Id", "acme"),
291    /// );
292    /// # let _ = transport;
293    /// # Ok(())
294    /// # }
295    /// ```
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if the underlying HTTP client cannot be built.
300    pub fn builder_with_timeout(
301        endpoint: impl Into<String>,
302        auth: McpAuth,
303        request_timeout: Duration,
304    ) -> Result<Self> {
305        Ok(Self::builder_with_timeout_parts(endpoint, auth, request_timeout)?.1)
306    }
307
308    /// Implementation of [`StreamableHttpTransport::builder_with_timeout`]
309    /// that also hands back the concrete poster, so tests can assert against
310    /// the exact instance wired into the transport (not a look-alike).
311    fn builder_with_timeout_parts(
312        endpoint: impl Into<String>,
313        auth: McpAuth,
314        request_timeout: Duration,
315    ) -> Result<(Arc<ReqwestPoster>, Self)> {
316        let poster = Arc::new(ReqwestPoster::with_timeout(endpoint, request_timeout)?);
317        let transport = Self::with_poster_owned(Arc::clone(&poster) as Arc<dyn HttpPoster>, auth)
318            .with_request_timeout(request_timeout);
319        Ok((poster, transport))
320    }
321
322    /// Create an un-wrapped transport backed by a custom [`HttpPoster`], for
323    /// further builder-style configuration before wrapping in `Arc`.
324    #[must_use]
325    pub fn with_poster_owned(poster: Arc<dyn HttpPoster>, auth: McpAuth) -> Self {
326        Self {
327            poster,
328            auth,
329            extra_headers: Vec::new(),
330            next_id: AtomicU64::new(1),
331            session_id: RwLock::new(None),
332            protocol_version: RwLock::new(None),
333            send_deadline: DEFAULT_SEND_DEADLINE,
334        }
335    }
336
337    /// Add a static custom header sent on every request (e.g. a tenant id).
338    ///
339    /// Call this on an un-wrapped transport from [`StreamableHttpTransport::builder`]
340    /// (or [`StreamableHttpTransport::with_poster_owned`]) before wrapping it in
341    /// `Arc`.
342    #[must_use]
343    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
344        self.extra_headers.push((name.into(), value.into()));
345        self
346    }
347
348    /// Set the overall per-request send deadline (default
349    /// [`DEFAULT_SEND_DEADLINE`]).
350    ///
351    /// This bounds every [`McpTransport::send`] / `send_notification` call
352    /// regardless of the underlying [`HttpPoster`]'s own timeout, so a custom
353    /// poster (or a [`ReqwestPoster`] whose client timeout is longer) still has
354    /// a guaranteed cancellation path. Call it on an un-wrapped transport from
355    /// [`StreamableHttpTransport::builder`] or
356    /// [`StreamableHttpTransport::with_poster_owned`] before wrapping in `Arc`.
357    ///
358    /// # This never raises the bound past the backing poster's own timeout
359    ///
360    /// The effective per-request bound is the **minimum** of this send deadline
361    /// and the backing [`HttpPoster`]'s own timeout. For a [`ReqwestPoster`]
362    /// built via [`StreamableHttpTransport::builder`] /
363    /// [`ReqwestPoster::new`], that client timeout is [`DEFAULT_HTTP_TIMEOUT`]
364    /// (60s), so:
365    ///
366    /// * **Lowering** works: `with_request_timeout(Duration::from_secs(5))`
367    ///   trips the send deadline at 5s, well before the client's 60s.
368    /// * **Raising does *not* work here:**
369    ///   `with_request_timeout(Duration::from_secs(300))` leaves the send
370    ///   deadline at 300s but the client still aborts the request at its own
371    ///   60s [`DEFAULT_HTTP_TIMEOUT`]. To genuinely raise the timeout, build the
372    ///   transport with [`StreamableHttpTransport::builder_with_timeout`] (or
373    ///   [`StreamableHttpTransport::with_timeout`]), which configures both the
374    ///   reqwest client timeout and this send deadline together.
375    #[must_use]
376    pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self {
377        // Surface the capped-raise footgun at configuration time: a deadline
378        // raised past the poster's own timeout never takes effect, because
379        // the poster aborts the request first.
380        if let Some(poster_timeout) = self.poster.timeout_hint()
381            && request_timeout > poster_timeout
382        {
383            log::warn!(
384                "MCP send deadline {request_timeout:?} exceeds the HTTP poster's own timeout \
385                 {poster_timeout:?}; requests will still abort at {poster_timeout:?}. To raise \
386                 the effective timeout, construct the transport with `builder_with_timeout` / \
387                 `with_timeout` instead."
388            );
389        }
390        self.send_deadline = request_timeout;
391        self
392    }
393
394    fn next_request_id(&self) -> u64 {
395        self.next_id.fetch_add(1, Ordering::SeqCst)
396    }
397
398    async fn build_http_request(&self, body: String) -> HttpRequest {
399        HttpRequest {
400            body,
401            authorization: self.auth.header_value(),
402            session_id: self.session_id.read().await.clone(),
403            protocol_version: self.protocol_version.read().await.clone(),
404            extra_headers: self.extra_headers.clone(),
405        }
406    }
407
408    /// Capture the session id from a reply if the server assigned one.
409    async fn capture_session_id(&self, reply: &HttpReply) {
410        if let Some(ref sid) = reply.session_id {
411            let mut guard = self.session_id.write().await;
412            if guard.as_deref() != Some(sid.as_str()) {
413                *guard = Some(sid.clone());
414            }
415        }
416    }
417
418    /// Overall send deadline configured on this transport.
419    ///
420    /// Test-only accessor used to assert the builder stores the caller's value
421    /// (or the documented default when none is given).
422    #[cfg(test)]
423    const fn send_deadline(&self) -> Duration {
424        self.send_deadline
425    }
426}
427
428/// Parse a normalised [`HttpReply`] into the JSON-RPC response matching `id`.
429///
430/// Handles both the single-JSON body and the SSE multi-event body. For SSE, the
431/// first `data:` payload that parses as a [`JsonRpcResponse`] whose `id` matches
432/// the request is returned; intervening server-initiated notifications/requests
433/// (which carry no matching `id`) are skipped.
434fn parse_reply(reply: &HttpReply, id: &RequestId) -> Result<JsonRpcResponse> {
435    if reply.content_type.contains("text/event-stream") {
436        parse_sse_response(&reply.body, id)
437    } else {
438        serde_json::from_str::<JsonRpcResponse>(reply.body.trim())
439            .context("failed to parse JSON MCP response body")
440    }
441}
442
443/// Compare two JSON-RPC ids, tolerating a server that echoes a numeric id as a
444/// string (or vice-versa) — but nothing looser.
445fn ids_match(a: &RequestId, b: &RequestId) -> bool {
446    match (a, b) {
447        (RequestId::Number(x), RequestId::Number(y)) => x == y,
448        (RequestId::String(x), RequestId::String(y)) => x == y,
449        (RequestId::Number(n), RequestId::String(s))
450        | (RequestId::String(s), RequestId::Number(n)) => s.parse::<u64>().ok() == Some(*n),
451    }
452}
453
454/// Extract the matching JSON-RPC response from an SSE body.
455///
456/// Returns the first `data:` payload that parses as a [`JsonRpcResponse`] whose
457/// `id` matches `id`. Server-initiated requests/notifications carried on the
458/// same stream (sampling, roots/list, elicitation — all of which include a
459/// `method` field) are skipped, and a message whose id does not match is *not*
460/// substituted as a fallback: if nothing matches, this is an error rather than
461/// silently returning the wrong message as the reply.
462fn parse_sse_response(body: &str, id: &RequestId) -> Result<JsonRpcResponse> {
463    let mut data_buf = String::new();
464
465    let try_match = |data: &mut String| -> Option<JsonRpcResponse> {
466        if data.is_empty() {
467            return None;
468        }
469        let raw = std::mem::take(data);
470        let trimmed = raw.trim();
471        // Skip server-initiated requests/notifications: those carry a `method`
472        // and are not a reply to our request, even though they deserialize into
473        // `JsonRpcResponse` (result/error both optional, unknown fields ignored).
474        if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
475            && value.get("method").is_some()
476        {
477            return None;
478        }
479        if let Ok(resp) = serde_json::from_str::<JsonRpcResponse>(trimmed)
480            && ids_match(&resp.id, id)
481        {
482            return Some(resp);
483        }
484        None
485    };
486
487    for line in body.lines() {
488        let line = line.trim_end_matches('\r');
489        if line.is_empty() {
490            // Event boundary: attempt to resolve the accumulated data block.
491            if let Some(resp) = try_match(&mut data_buf) {
492                return Ok(resp);
493            }
494            continue;
495        }
496        // SSE `data:` lines (optionally with a leading space) carry the payload.
497        if let Some(rest) = line.strip_prefix("data:") {
498            let rest = rest.strip_prefix(' ').unwrap_or(rest);
499            if !data_buf.is_empty() {
500                data_buf.push('\n');
501            }
502            data_buf.push_str(rest);
503        }
504        // Other SSE fields (`event:`, `id:`, comments) are ignored.
505    }
506    // Flush any trailing event with no terminating blank line.
507    if let Some(resp) = try_match(&mut data_buf) {
508        return Ok(resp);
509    }
510
511    bail!("SSE stream contained no JSON-RPC response matching the request id")
512}
513
514#[async_trait]
515impl McpTransport for StreamableHttpTransport {
516    async fn send(&self, mut request: JsonRpcRequest) -> Result<JsonRpcResponse> {
517        let id = self.next_request_id();
518        request.id = RequestId::Number(id);
519        let request_id = request.id.clone();
520
521        let body = serde_json::to_string(&request).context("failed to serialize MCP request")?;
522        let http_request = self.build_http_request(body).await;
523        // Overall deadline so a hung/keep-alive server can never wedge a turn.
524        let reply = tokio::time::timeout(self.send_deadline, self.poster.post(http_request))
525            .await
526            .context("MCP HTTP request timed out")??;
527        self.capture_session_id(&reply).await;
528
529        let response = parse_reply(&reply, &request_id)?;
530
531        if let Some(ref error) = response.error {
532            bail!("JSON-RPC error {}: {}", error.code, error.message);
533        }
534        Ok(response)
535    }
536
537    async fn send_notification(&self, mut request: JsonRpcRequest) -> Result<()> {
538        // Advance the shared id counter so request ids stay monotonic across the
539        // connection, but strip the id on the wire: JSON-RPC 2.0 / MCP
540        // notifications must not carry one.
541        let id = self.next_request_id();
542        request.id = RequestId::Number(id);
543        let body = notification_body(&request)?;
544        let http_request = self.build_http_request(body).await;
545        let reply = tokio::time::timeout(self.send_deadline, self.poster.post(http_request))
546            .await
547            .context("MCP HTTP request timed out")??;
548        self.capture_session_id(&reply).await;
549        Ok(())
550    }
551
552    async fn set_protocol_version(&self, version: &str) {
553        let mut guard = self.protocol_version.write().await;
554        *guard = Some(version.to_string());
555    }
556
557    async fn close(&self) -> Result<()> {
558        Ok(())
559    }
560}
561
562/// Default [`HttpPoster`] backed by `reqwest`.
563pub struct ReqwestPoster {
564    client: reqwest::Client,
565    endpoint: String,
566    /// Request timeout the backing client was built with. `reqwest::Client`
567    /// does not expose its configured timeout, so we record it to let tests
568    /// assert that a *raised* timeout actually reaches the client (not just the
569    /// transport's send deadline).
570    configured_timeout: Option<Duration>,
571}
572
573impl ReqwestPoster {
574    /// Build a reqwest-backed poster for `endpoint`.
575    ///
576    /// The client is given a default request timeout
577    /// (`DEFAULT_HTTP_TIMEOUT`) so a slow, hung, or keep-alive SSE server
578    /// cannot block a request forever. Use [`ReqwestPoster::with_client`] to
579    /// supply a client with different settings.
580    ///
581    /// # Errors
582    ///
583    /// Returns an error if the HTTP client cannot be constructed.
584    pub fn new(endpoint: impl Into<String>) -> Result<Self> {
585        Self::with_timeout(endpoint, DEFAULT_HTTP_TIMEOUT)
586    }
587
588    /// Build a reqwest-backed poster for `endpoint` with a custom request
589    /// timeout instead of [`DEFAULT_HTTP_TIMEOUT`].
590    ///
591    /// # Errors
592    ///
593    /// Returns an error if the HTTP client cannot be constructed.
594    pub fn with_timeout(endpoint: impl Into<String>, timeout: Duration) -> Result<Self> {
595        let client = reqwest::Client::builder()
596            .timeout(timeout)
597            .build()
598            .context("failed to build MCP HTTP client")?;
599        Ok(Self {
600            client,
601            endpoint: endpoint.into(),
602            configured_timeout: Some(timeout),
603        })
604    }
605
606    /// Build a poster from a caller-supplied `reqwest::Client`.
607    #[must_use]
608    pub fn with_client(client: reqwest::Client, endpoint: impl Into<String>) -> Self {
609        Self {
610            client,
611            endpoint: endpoint.into(),
612            configured_timeout: None,
613        }
614    }
615}
616
617#[async_trait]
618impl HttpPoster for ReqwestPoster {
619    /// `reqwest::Client` does not expose its configured timeout, so this
620    /// reports the value recorded at construction; `None` for posters built
621    /// from a caller-supplied client via [`ReqwestPoster::with_client`].
622    fn timeout_hint(&self) -> Option<Duration> {
623        self.configured_timeout
624    }
625
626    async fn post(&self, request: HttpRequest) -> Result<HttpReply> {
627        let mut builder = self
628            .client
629            .post(&self.endpoint)
630            // The streamable-HTTP spec requires the client to accept both shapes.
631            .header(
632                reqwest::header::ACCEPT,
633                "application/json, text/event-stream",
634            )
635            .header(reqwest::header::CONTENT_TYPE, "application/json")
636            .body(request.body);
637
638        if let Some(auth) = request.authorization {
639            builder = builder.header(reqwest::header::AUTHORIZATION, auth);
640        }
641        if let Some(sid) = request.session_id {
642            builder = builder.header(SESSION_ID_HEADER, sid);
643        }
644        if let Some(version) = request.protocol_version {
645            builder = builder.header(PROTOCOL_VERSION_HEADER, version);
646        }
647        for (name, value) in request.extra_headers {
648            builder = builder.header(name, value);
649        }
650
651        let mut response = builder
652            .send()
653            .await
654            .context("MCP HTTP request failed to send")?;
655
656        let status = response.status();
657        let session_id = response
658            .headers()
659            .get(SESSION_ID_HEADER)
660            .and_then(|v| v.to_str().ok())
661            .map(ToString::to_string);
662        let content_type = response
663            .headers()
664            .get(reqwest::header::CONTENT_TYPE)
665            .and_then(|v| v.to_str().ok())
666            .map_or_else(
667                || "application/json".to_string(),
668                |s| s.split(';').next().unwrap_or(s).trim().to_lowercase(),
669            );
670
671        // Read the body incrementally with a hard cap so an endless SSE stream
672        // (kept open with keep-alive comments) cannot grow memory without bound.
673        let mut body_bytes: Vec<u8> = Vec::new();
674        while let Some(chunk) = response
675            .chunk()
676            .await
677            .context("failed to read MCP HTTP response body")?
678        {
679            if body_bytes.len() + chunk.len() > MAX_RESPONSE_BODY_BYTES {
680                bail!("MCP HTTP response body exceeds {MAX_RESPONSE_BODY_BYTES} bytes");
681            }
682            body_bytes.extend_from_slice(&chunk);
683        }
684        let body = String::from_utf8_lossy(&body_bytes).into_owned();
685
686        if !status.is_success() {
687            bail!("MCP HTTP request returned status {status}: {body}");
688        }
689
690        Ok(HttpReply {
691            content_type,
692            body,
693            session_id,
694        })
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    fn ok_response(id: u64, result: &serde_json::Value) -> String {
703        serde_json::json!({
704            "jsonrpc": "2.0",
705            "id": id,
706            "result": result,
707        })
708        .to_string()
709    }
710
711    #[test]
712    fn parse_json_body() {
713        let reply = HttpReply::json(ok_response(1, &serde_json::json!({"ok": true})));
714        let resp = parse_reply(&reply, &RequestId::Number(1)).expect("parse");
715        assert!(!resp.is_error());
716        assert!(resp.result().is_some());
717    }
718
719    #[test]
720    fn parse_sse_single_event() {
721        let body = format!(
722            "event: message\ndata: {}\n\n",
723            ok_response(2, &serde_json::json!({}))
724        );
725        let reply = HttpReply::event_stream(body);
726        let resp = parse_reply(&reply, &RequestId::Number(2)).expect("parse");
727        assert_eq!(resp.id, RequestId::Number(2));
728    }
729
730    #[test]
731    fn parse_sse_skips_non_matching_then_matches() {
732        // A server-initiated notification-shaped message (id 99) precedes the
733        // real response (id 3); the parser must skip ahead to the match.
734        let body = format!(
735            "data: {}\n\ndata: {}\n\n",
736            ok_response(99, &serde_json::json!({"unrelated": true})),
737            ok_response(3, &serde_json::json!({"answer": 42})),
738        );
739        let reply = HttpReply::event_stream(body);
740        let resp = parse_reply(&reply, &RequestId::Number(3)).expect("parse");
741        assert_eq!(resp.id, RequestId::Number(3));
742    }
743
744    #[test]
745    fn parse_sse_multiline_data() {
746        // SSE allows a payload to be split across consecutive `data:` lines,
747        // re-joined with newlines.
748        let body = "data: {\"jsonrpc\":\"2.0\",\ndata: \"id\":4,\ndata: \"result\":{}}\n\n";
749        let reply = HttpReply::event_stream(body.to_string());
750        let resp = parse_reply(&reply, &RequestId::Number(4)).expect("parse");
751        assert_eq!(resp.id, RequestId::Number(4));
752    }
753
754    /// The transport defaults to the documented send deadline, and
755    /// `with_request_timeout` overrides it.
756    #[test]
757    fn send_deadline_defaults_and_overrides() {
758        let poster = Arc::new(CapturingPoster {
759            last_body: std::sync::Mutex::new(None),
760        });
761        let default = StreamableHttpTransport::with_poster_owned(poster.clone(), McpAuth::None);
762        assert_eq!(default.send_deadline(), DEFAULT_SEND_DEADLINE);
763
764        let custom = StreamableHttpTransport::with_poster_owned(poster, McpAuth::None)
765            .with_request_timeout(Duration::from_millis(250));
766        assert_eq!(custom.send_deadline(), Duration::from_millis(250));
767    }
768
769    /// `ReqwestPoster::with_timeout` and `StreamableHttpTransport::with_timeout`
770    /// must build successfully and the transport must record the configured
771    /// deadline.
772    #[test]
773    fn reqwest_with_timeout_builds_and_transport_records_deadline() -> Result<()> {
774        ReqwestPoster::with_timeout("https://example.com/mcp", Duration::from_secs(5))?;
775        let transport = StreamableHttpTransport::with_timeout(
776            "https://example.com/mcp",
777            McpAuth::None,
778            Duration::from_secs(5),
779        )?;
780        assert_eq!(transport.send_deadline(), Duration::from_secs(5));
781        Ok(())
782    }
783
784    /// Regression test for the builder-path footgun: a *raised* request timeout
785    /// (300s, well past the 60s [`DEFAULT_HTTP_TIMEOUT`]) must reach BOTH the
786    /// backing reqwest client and the transport send deadline. Previously,
787    /// raising the timeout via the builder silently left the client capped at
788    /// [`DEFAULT_HTTP_TIMEOUT`], so only lowering ever took effect.
789    #[test]
790    fn builder_with_timeout_raises_client_timeout_and_send_deadline() -> Result<()> {
791        let raised = Duration::from_mins(5);
792
793        // Assert against the exact poster instance wired into the transport
794        // (not a separately-built look-alike): if `builder_with_timeout`
795        // regressed to a default-timeout poster while still setting the send
796        // deadline, this catches it.
797        let (poster, transport) = StreamableHttpTransport::builder_with_timeout_parts(
798            "https://example.com/mcp",
799            McpAuth::None,
800            raised,
801        )?;
802        assert_eq!(
803            poster.timeout_hint(),
804            Some(raised),
805            "raised timeout must reach the reqwest client, not stay at DEFAULT_HTTP_TIMEOUT"
806        );
807        assert_eq!(transport.send_deadline(), raised);
808
809        Ok(())
810    }
811
812    /// A poster that stalls forever must trip the configured send deadline
813    /// quickly rather than blocking for the full default minute. Paused time
814    /// makes both directions instant and deterministic: the sleep and the
815    /// deadline are virtual, so neither the happy path nor a regression
816    /// burns wall-clock time.
817    ///
818    /// The assertions pin the *virtual* elapsed time and the timeout error
819    /// provenance — not merely "some error": if `send` regressed to the
820    /// default deadline (or dropped the outer timeout), paused time would
821    /// auto-advance through the 30s stall and the poster's junk body would
822    /// still make `send` fail, but at 30s virtual and without the timeout
823    /// context, so both assertions below catch it.
824    #[tokio::test(start_paused = true)]
825    async fn configured_send_deadline_fails_fast() -> Result<()> {
826        struct StallingPoster;
827
828        #[async_trait]
829        impl HttpPoster for StallingPoster {
830            async fn post(&self, _request: HttpRequest) -> Result<HttpReply> {
831                // Far longer than the configured deadline; the outer timeout
832                // cancels this future well before it resolves.
833                tokio::time::sleep(Duration::from_secs(30)).await;
834                Ok(HttpReply::json("{}"))
835            }
836        }
837
838        let transport = Arc::new(
839            StreamableHttpTransport::with_poster_owned(Arc::new(StallingPoster), McpAuth::None)
840                .with_request_timeout(Duration::from_millis(50)),
841        );
842
843        let started = tokio::time::Instant::now();
844        let Err(error) = transport.send(JsonRpcRequest::new("ping", None, 0)).await else {
845            bail!("a stalled server must trip the configured send deadline");
846        };
847        assert!(
848            format!("{error:#}").contains("timed out"),
849            "error must come from the send deadline, not response parsing: {error:#}"
850        );
851        assert!(
852            started.elapsed() < Duration::from_secs(1),
853            "deadline must fire at the configured 50ms (virtual), not after the 30s stall \
854             (virtual elapsed {:?})",
855            started.elapsed(),
856        );
857        Ok(())
858    }
859
860    #[test]
861    fn bearer_auth_header_value() {
862        assert_eq!(McpAuth::None.header_value(), None);
863        assert_eq!(
864            McpAuth::Bearer("tok".to_string()).header_value().as_deref(),
865            Some("Bearer tok"),
866        );
867    }
868
869    /// Regression test for finding 8: an SSE stream that contains no message
870    /// matching the request id must error, not return a non-matching message as
871    /// a fallback (which previously masked server-initiated messages as the
872    /// reply).
873    #[test]
874    fn parse_sse_no_matching_id_is_error() {
875        let body = format!(
876            "data: {}\n\n",
877            ok_response(99, &serde_json::json!({"x": 1}))
878        );
879        let reply = HttpReply::event_stream(body);
880        let result = parse_reply(&reply, &RequestId::Number(3));
881        assert!(
882            result.is_err(),
883            "a stream with no matching id must error rather than return a fallback"
884        );
885    }
886
887    /// Regression test for finding 8: a server-initiated request carried on the
888    /// stream (it has a `method` and even shares our id) must be skipped, and
889    /// the real reply returned.
890    #[test]
891    fn parse_sse_skips_server_request_with_method() -> Result<()> {
892        let server_request = serde_json::json!({
893            "jsonrpc": "2.0",
894            "id": 3,
895            "method": "sampling/createMessage",
896            "params": {},
897        })
898        .to_string();
899        let body = format!(
900            "data: {server_request}\n\ndata: {}\n\n",
901            ok_response(3, &serde_json::json!({"answer": 42})),
902        );
903        let reply = HttpReply::event_stream(body);
904        let resp = parse_reply(&reply, &RequestId::Number(3))?;
905        assert_eq!(resp.id, RequestId::Number(3));
906        assert!(
907            resp.result().is_some(),
908            "must return the real reply, not the server request"
909        );
910        Ok(())
911    }
912
913    #[test]
914    fn ids_match_coerces_numeric_string() {
915        assert!(ids_match(&RequestId::Number(5), &RequestId::Number(5)));
916        assert!(ids_match(
917            &RequestId::Number(5),
918            &RequestId::String("5".to_string())
919        ));
920        assert!(ids_match(
921            &RequestId::String("5".to_string()),
922            &RequestId::Number(5)
923        ));
924        assert!(!ids_match(
925            &RequestId::Number(5),
926            &RequestId::String("six".to_string())
927        ));
928        assert!(!ids_match(&RequestId::Number(5), &RequestId::Number(6)));
929    }
930
931    /// Poster that records the most recent body it was asked to POST.
932    struct CapturingPoster {
933        last_body: std::sync::Mutex<Option<String>>,
934    }
935
936    #[async_trait]
937    impl HttpPoster for CapturingPoster {
938        async fn post(&self, request: HttpRequest) -> Result<HttpReply> {
939            *self
940                .last_body
941                .lock()
942                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(request.body);
943            Ok(HttpReply::json(ok_response(1, &serde_json::json!({}))))
944        }
945    }
946
947    /// Regression test for finding 13: HTTP notifications must be serialized
948    /// without an `id`.
949    #[tokio::test]
950    async fn send_notification_omits_id() -> Result<()> {
951        let poster = Arc::new(CapturingPoster {
952            last_body: std::sync::Mutex::new(None),
953        });
954        let transport = StreamableHttpTransport::with_poster(poster.clone(), McpAuth::None);
955
956        transport
957            .send_notification(JsonRpcRequest::new("notifications/initialized", None, 0))
958            .await?;
959
960        let body = poster
961            .last_body
962            .lock()
963            .unwrap_or_else(std::sync::PoisonError::into_inner)
964            .clone()
965            .context("no body captured")?;
966        let value: serde_json::Value = serde_json::from_str(&body)?;
967        assert!(
968            value.get("id").is_none(),
969            "notification must not carry an id, got: {body}"
970        );
971        assert_eq!(
972            value.get("method").and_then(serde_json::Value::as_str),
973            Some("notifications/initialized")
974        );
975        Ok(())
976    }
977
978    /// `with_header` must be reachable via the builder and the header must be
979    /// forwarded on requests (finding 7).
980    #[tokio::test]
981    async fn builder_with_header_is_forwarded() -> Result<()> {
982        struct HeaderCapturingPoster {
983            headers: std::sync::Mutex<Vec<(String, String)>>,
984        }
985
986        #[async_trait]
987        impl HttpPoster for HeaderCapturingPoster {
988            async fn post(&self, request: HttpRequest) -> Result<HttpReply> {
989                *self
990                    .headers
991                    .lock()
992                    .unwrap_or_else(std::sync::PoisonError::into_inner) = request.extra_headers;
993                Ok(HttpReply::json(ok_response(1, &serde_json::json!({}))))
994            }
995        }
996
997        let poster = Arc::new(HeaderCapturingPoster {
998            headers: std::sync::Mutex::new(Vec::new()),
999        });
1000        let transport = Arc::new(
1001            StreamableHttpTransport::with_poster_owned(poster.clone(), McpAuth::None)
1002                .with_header("X-Tenant-Id", "acme"),
1003        );
1004
1005        transport.send(JsonRpcRequest::new("ping", None, 0)).await?;
1006
1007        let headers = poster
1008            .headers
1009            .lock()
1010            .unwrap_or_else(std::sync::PoisonError::into_inner)
1011            .clone();
1012        assert!(
1013            headers
1014                .iter()
1015                .any(|(k, v)| k == "X-Tenant-Id" && v == "acme"),
1016            "custom header set via builder must be forwarded, got: {headers:?}"
1017        );
1018        Ok(())
1019    }
1020}