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}