Skip to main content

tower_mcp/client/
http.rs

1//! HTTP client transport for remote MCP servers.
2//!
3//! Provides [`HttpClientTransport`] which connects to an MCP server using
4//! the Streamable HTTP transport protocol (MCP spec 2025-11-25). Manages
5//! session lifecycle, SSE stream for server notifications, and HTTP POST
6//! for client requests.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use tower_mcp::client::{McpClient, HttpClientTransport};
12//!
13//! # async fn example() -> Result<(), tower_mcp::BoxError> {
14//! let transport = HttpClientTransport::new("http://localhost:3000");
15//! let client = McpClient::connect(transport).await?;
16//!
17//! let info = client.initialize("my-client", "1.0.0").await?;
18//! println!("Connected to: {}", info.server_info.name);
19//! # Ok(())
20//! # }
21//! ```
22//!
23//! # Authentication
24//!
25//! Transport diagnostics omit request/redirect URLs, raw HTTP error bodies,
26//! and arbitrary source chains, which can contain credentials. MCP JSON-RPC
27//! errors are still forwarded as protocol data; [`HttpStatusDetails`] retains
28//! authentication challenges and retry metadata. Treat that data as sensitive
29//! when logging it yourself.
30//!
31//! ```rust,no_run
32//! use tower_mcp::client::{McpClient, HttpClientTransport};
33//!
34//! # async fn example() -> Result<(), tower_mcp::BoxError> {
35//! // Bearer token
36//! let transport = HttpClientTransport::new("http://localhost:3000")
37//!     .bearer_token("sk-your-token-here");
38//!
39//! // Custom API key header
40//! let transport = HttpClientTransport::new("http://localhost:3000")
41//!     .api_key_header("X-API-Key", "your-key");
42//!
43//! // Basic auth
44//! let transport = HttpClientTransport::new("http://localhost:3000")
45//!     .basic_auth("user", "password");
46//! # Ok(())
47//! # }
48//! ```
49
50use std::collections::HashMap;
51use std::sync::Arc;
52use std::sync::atomic::{AtomicBool, Ordering};
53use std::time::Duration;
54
55use async_trait::async_trait;
56use base64::Engine;
57use tokio::sync::{Notify, RwLock, mpsc};
58use tokio::task::JoinHandle;
59
60use super::transport::ClientTransport;
61use crate::error::{Error, Result};
62use crate::protocol::{RequestId, notifications};
63
64const MCP_METHOD_HEADER: &str = "mcp-method";
65const MCP_NAME_HEADER: &str = "mcp-name";
66const MCP_PARAM_HEADER_PREFIX: &str = "mcp-param-";
67const BASE64_SENTINEL_PREFIX: &str = "=?base64?";
68const BASE64_SENTINEL_SUFFIX: &str = "?=";
69/// Timeout for the best-effort session-termination `DELETE`, used by both
70/// `close()` and the `Drop` impl.
71const SESSION_DELETE_TIMEOUT: Duration = Duration::from_secs(5);
72
73#[derive(Debug, Clone)]
74struct CustomHeaderMapping {
75    suffix: String,
76    property_path: Vec<String>,
77}
78
79#[cfg(feature = "oauth-client")]
80#[derive(Clone)]
81struct ScopeEscalationRuntime {
82    handler: Arc<dyn OAuthScopeEscalationHandler>,
83    state: Arc<tokio::sync::Mutex<ScopeEscalationState>>,
84    max_attempts: usize,
85}
86
87#[cfg(feature = "oauth-client")]
88struct ScopeEscalationState {
89    scopes: Vec<String>,
90    revision: usize,
91}
92
93#[cfg(feature = "oauth-client")]
94impl ScopeEscalationRuntime {
95    fn new<P>(handler: Arc<P>, config: OAuthScopeEscalationConfig) -> Self
96    where
97        P: OAuthScopeEscalationHandler,
98    {
99        Self {
100            handler,
101            state: Arc::new(tokio::sync::Mutex::new(ScopeEscalationState {
102                scopes: config.initial_scopes().to_vec(),
103                revision: 0,
104            })),
105            max_attempts: config.maximum_attempts(),
106        }
107    }
108
109    async fn respond_to_challenge(
110        &self,
111        challenge: OAuthScopeChallenge,
112        resource: &str,
113        operation: &str,
114        attempt: usize,
115        observed_revision: usize,
116    ) -> std::result::Result<ScopeEscalationDecision, OAuthClientError> {
117        // Serialize the complete reauthorization flow. A second operation
118        // challenged by the same scope can reuse the token produced by the
119        // first rather than opening a duplicate browser/headless flow.
120        let mut state = self.state.lock().await;
121        let previous_scopes = state.scopes.clone();
122        let mut requested_scopes = previous_scopes.clone();
123        for scope in &challenge.required_scopes {
124            if !requested_scopes.contains(scope) {
125                requested_scopes.push(scope.clone());
126            }
127        }
128
129        if requested_scopes == previous_scopes && state.revision > observed_revision {
130            return Ok(ScopeEscalationDecision {
131                revision: state.revision,
132            });
133        }
134        // The scope may have been requested previously without being granted,
135        // or the token may otherwise be stale. Reauthorize the same union
136        // again, but only within the caller's hard attempt limit.
137
138        self.handler
139            .reauthorize(OAuthScopeEscalationRequest {
140                resource: resource.to_string(),
141                operation: operation.to_string(),
142                challenge,
143                previous_scopes,
144                requested_scopes: requested_scopes.clone(),
145                attempt,
146            })
147            .await?;
148
149        state.scopes = requested_scopes;
150        state.revision += 1;
151        Ok(ScopeEscalationDecision {
152            revision: state.revision,
153        })
154    }
155}
156
157#[cfg(feature = "oauth-client")]
158struct ScopeEscalationDecision {
159    revision: usize,
160}
161
162#[cfg(feature = "oauth-client")]
163use super::oauth::{
164    OAuthBearerChallenge, OAuthClientError, OAuthScopeChallenge, OAuthScopeEscalationConfig,
165    OAuthScopeEscalationHandler, OAuthScopeEscalationRequest, TokenProvider,
166};
167
168/// Configuration for [`HttpClientTransport`].
169///
170/// # Example
171///
172/// ```rust,no_run
173/// use tower_mcp::client::{HttpClientTransport, HttpClientConfig};
174/// use std::time::Duration;
175///
176/// let config = HttpClientConfig {
177///     request_timeout: Duration::from_secs(60),
178///     ..Default::default()
179/// };
180/// let transport = HttpClientTransport::with_config("http://localhost:3000", config);
181/// ```
182#[derive(Debug, Clone)]
183pub struct HttpClientConfig {
184    /// Custom headers to include on every request (e.g., auth tokens).
185    pub headers: HashMap<String, String>,
186    /// Whether to automatically open the standalone SSE notification stream
187    /// after initialization. Associated POST response streams used for
188    /// sampling, elicitation, and roots requests work independently.
189    /// Default: `true`.
190    pub auto_sse: bool,
191    /// Capacity of the internal message channel.
192    /// Default: 256.
193    pub channel_capacity: usize,
194    /// Timeout for HTTP requests.
195    /// Default: 30 seconds.
196    pub request_timeout: Duration,
197    /// Timeout for notification POSTs (frames without an `id`), capped at
198    /// `request_timeout`.
199    ///
200    /// Notifications are awaited inline so `notifications/initialized` is
201    /// ordered ahead of the first request, which blocks the client's message
202    /// loop for the duration. This bounds that block independently of
203    /// `request_timeout` so a server that stalls a notification's `202` cannot
204    /// freeze the client for the full request timeout.
205    /// Default: 5 seconds.
206    pub notification_timeout: Duration,
207    /// Whether to attempt SSE reconnection on disconnect.
208    /// Default: `true`.
209    pub sse_reconnect: bool,
210    /// Delay before SSE reconnection attempts.
211    /// Default: 1 second.
212    pub sse_reconnect_delay: Duration,
213    /// Maximum SSE reconnection attempts before giving up.
214    /// Default: 5.
215    pub max_sse_reconnect_attempts: u32,
216    /// Whether to support automatic session recovery on expiry.
217    /// When enabled, HTTP 404 responses (with a session ID attached) and
218    /// JSON-RPC -32005 errors trigger re-initialization. A 404 counts
219    /// whatever its body holds.
220    /// Default: `true`.
221    pub session_recovery: bool,
222    /// Maximum size in bytes buffered for a single SSE event.
223    ///
224    /// A server that streams an event without ever terminating it would
225    /// otherwise grow the parse buffer without bound. When a single
226    /// event's buffered size exceeds this cap, the stream is terminated
227    /// with [`Error::SseEventTooLarge`].
228    /// Default: 16 MiB (matching rmcp).
229    pub max_sse_event_size: usize,
230    /// Maximum size in bytes buffered for a single non-SSE response body: a
231    /// JSON-RPC response on the request path, or an error body.
232    ///
233    /// A server that answers with a JSON body that never ends (or is simply
234    /// very large) would otherwise be buffered into memory without bound
235    /// until the request timeout fires. This is checked against
236    /// `Content-Length` up front and while streaming, so a response over the
237    /// cap fails that request with a transport error instead; the
238    /// connection itself is left open. Error bodies (used only in messages)
239    /// are truncated rather than failed, independent of this setting.
240    /// Default: 16 MiB (matching `max_sse_event_size`).
241    pub max_response_body_size: usize,
242    /// Whether to follow a redirect to a different origin (scheme, host, or
243    /// port) than the one the request was sent to.
244    ///
245    /// By default the client follows only same-origin redirects, so a
246    /// same-host `307`/`308` still works, but a server (or an attacker who
247    /// controls a `Location` response) cannot redirect a request to another
248    /// host and have this client forward it there. This matters because
249    /// every header on the request, including custom headers set via
250    /// [`HttpClientConfig::headers`] (`bearer_token`, `api_key_header`,
251    /// `header`) and `mcp-session-id`, is resent to the redirect target;
252    /// reqwest's own stripping only covers `Authorization`, `Cookie`, and
253    /// `Proxy-Authorization`. Set this to `true` only when the server is
254    /// known to redirect across origins and the forwarded headers are safe
255    /// to disclose there. A redirect from `https` to a non-`https` scheme is
256    /// never followed, even with this enabled, since that would send those
257    /// headers in plaintext.
258    ///
259    /// This only widens where a redirect may lead. Whatever the origin, the
260    /// client follows only `307` and `308`: they resend the request with its
261    /// method and body, whereas `301`, `302`, and `303` would turn the
262    /// JSON-RPC `POST` into a bodyless `GET`. Those statuses are not followed;
263    /// the request fails with the 3xx status, so point the client at the
264    /// final URL.
265    /// Default: `false`.
266    pub follow_cross_origin_redirects: bool,
267}
268
269/// Default maximum buffered size for a single SSE event (16 MiB, matching
270/// rmcp). See [`HttpClientConfig::max_sse_event_size`].
271pub const DEFAULT_MAX_SSE_EVENT_SIZE: usize = 16 * 1024 * 1024;
272
273/// Default maximum buffered size for a single non-SSE response body.
274/// Matches [`DEFAULT_MAX_SSE_EVENT_SIZE`]. See
275/// [`HttpClientConfig::max_response_body_size`].
276pub const DEFAULT_MAX_RESPONSE_BODY_SIZE: usize = DEFAULT_MAX_SSE_EVENT_SIZE;
277
278impl Default for HttpClientConfig {
279    fn default() -> Self {
280        Self {
281            headers: HashMap::new(),
282            auto_sse: true,
283            channel_capacity: 256,
284            request_timeout: Duration::from_secs(30),
285            notification_timeout: Duration::from_secs(5),
286            sse_reconnect: true,
287            sse_reconnect_delay: Duration::from_secs(1),
288            max_sse_reconnect_attempts: 5,
289            session_recovery: true,
290            max_sse_event_size: DEFAULT_MAX_SSE_EVENT_SIZE,
291            max_response_body_size: DEFAULT_MAX_RESPONSE_BODY_SIZE,
292            follow_cross_origin_redirects: false,
293        }
294    }
295}
296
297impl HttpClientConfig {
298    /// Set a Bearer token for authentication.
299    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
300        self.headers.insert(
301            "Authorization".to_string(),
302            format!("Bearer {}", token.into()),
303        );
304        self
305    }
306
307    /// Set an API key using a custom header name.
308    pub fn api_key_header(mut self, name: impl Into<String>, key: impl Into<String>) -> Self {
309        self.headers.insert(name.into(), key.into());
310        self
311    }
312
313    /// Set Basic authentication credentials.
314    pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
315        use base64::Engine;
316        let encoded = base64::engine::general_purpose::STANDARD.encode(format!(
317            "{}:{}",
318            username.as_ref(),
319            password.as_ref()
320        ));
321        self.headers
322            .insert("Authorization".to_string(), format!("Basic {}", encoded));
323        self
324    }
325
326    /// Add a custom header.
327    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
328        self.headers.insert(name.into(), value.into());
329        self
330    }
331}
332
333/// Maximum redirect hops the custom redirect policy follows before failing,
334/// matching reqwest's own default (see [`reqwest::redirect::Policy`]).
335/// `Policy::custom` does not get that bound for free; it has to be checked
336/// explicitly.
337const MAX_REDIRECT_HOPS: usize = 10;
338
339/// Cap on [`HttpClientTransport::pending_tools_lists`], so requests that never
340/// get a response cannot grow it without bound. The oldest entry is dropped
341/// first, and a response with no entry is treated as a first page.
342const MAX_PENDING_TOOLS_LISTS: usize = 64;
343
344/// Whether a redirect with this status is safe to follow for an MCP request.
345///
346/// Only `307` and `308` resend the request with its method and body. For
347/// `301`, `302`, and `303` reqwest rewrites a `POST` into a bodyless `GET`, so
348/// the JSON-RPC message would be dropped and the server would answer a request
349/// it never received. The redirect policy cannot see the method, so this
350/// applies to the SSE `GET` and the session `DELETE` too; the `initialize`
351/// `POST` has to succeed before either is sent.
352fn redirect_preserves_request(status: reqwest::StatusCode) -> bool {
353    matches!(
354        status,
355        reqwest::StatusCode::TEMPORARY_REDIRECT | reqwest::StatusCode::PERMANENT_REDIRECT
356    )
357}
358
359/// Whether a redirect from `original` (the request's starting URL) to
360/// `target` (the `Location` it was just pointed at) should be followed.
361///
362/// Same-origin means matching scheme, host, and port, with each scheme's
363/// default port substituted when a URL omits one. A downgrade from `https`
364/// to any other scheme is refused unconditionally, since it would put
365/// whatever headers are on the request on the wire in plaintext; that check
366/// runs even when `allow_cross_origin` opts into following other origins.
367///
368/// Factored out of the `reqwest::redirect::Policy::custom` closure so it can
369/// be unit-tested without standing up TLS.
370fn should_follow_redirect(
371    original: &reqwest::Url,
372    target: &reqwest::Url,
373    allow_cross_origin: bool,
374) -> bool {
375    if original.scheme() == "https" && target.scheme() != "https" {
376        return false;
377    }
378    if allow_cross_origin {
379        return true;
380    }
381    original.scheme() == target.scheme()
382        && original.host_str() == target.host_str()
383        && original.port_or_known_default() == target.port_or_known_default()
384}
385
386/// Build the `reqwest::Client` used for MCP requests, with a redirect policy
387/// that keeps credentials (custom headers, `mcp-session-id`) from leaking to
388/// a redirect target reqwest's own header stripping does not cover. See
389/// [`HttpClientConfig::follow_cross_origin_redirects`].
390fn build_client(allow_cross_origin_redirects: bool) -> reqwest::Client {
391    let policy = reqwest::redirect::Policy::custom(move |attempt| {
392        // Hand the 3xx response back instead of following it: the caller then
393        // gets a plain non-success status (see `http_status_error`), and the
394        // transport stays connected, unlike a policy error.
395        if !redirect_preserves_request(attempt.status()) {
396            return attempt.stop();
397        }
398        if attempt.previous().len() > MAX_REDIRECT_HOPS {
399            return attempt.error("too many redirects");
400        }
401        // `previous()` holds every URL requested so far in this chain; the
402        // first entry is the original request, never a redirect target.
403        let Some(original) = attempt.previous().first() else {
404            return attempt.follow();
405        };
406        if should_follow_redirect(original, attempt.url(), allow_cross_origin_redirects) {
407            attempt.follow()
408        } else {
409            attempt.error(
410                "redirect to a different origin was not followed; set \
411                 HttpClientConfig::follow_cross_origin_redirects to allow it (an https-to-http \
412                 downgrade is never followed)",
413            )
414        }
415    });
416    reqwest::Client::builder()
417        .redirect(policy)
418        .build()
419        .expect("reqwest client with a custom redirect policy should always build")
420}
421
422/// Client transport for MCP servers over Streamable HTTP.
423///
424/// Connects to a remote MCP server using the Streamable HTTP transport
425/// protocol. Manages session lifecycle (`mcp-session-id`), opens an SSE
426/// stream for server-initiated messages, and sends client requests via
427/// HTTP POST.
428///
429/// Transport-generated diagnostics omit URLs, raw HTTP error bodies, and
430/// untrusted error details. They retain error categories and HTTP status codes.
431/// MCP JSON-RPC errors and [`HttpStatusDetails`] remain protocol data and may
432/// contain sensitive values; avoid logging them verbatim.
433///
434/// # How it works
435///
436/// The transport bridges HTTP's request/response model with the
437/// `ClientTransport` trait's `send()`/`recv()` message-passing model:
438///
439/// - **`send()`** POSTs JSON-RPC messages to the server and queues the
440///   response body into an internal channel for `recv()` to return.
441/// - **`recv()`** reads from that channel, which also receives SSE events
442///   from a background task.
443///
444/// After the `initialize` handshake establishes a session, an SSE stream
445/// is automatically opened to receive server notifications and
446/// server-initiated requests.
447///
448/// # Example
449///
450/// ```rust,no_run
451/// use tower_mcp::client::{McpClient, HttpClientTransport};
452///
453/// # async fn example() -> Result<(), tower_mcp::BoxError> {
454/// let transport = HttpClientTransport::new("http://localhost:3000");
455/// let client = McpClient::connect(transport).await?;
456///
457/// let info = client.initialize("my-client", "1.0.0").await?;
458/// let tools = client.list_tools().await?;
459/// client.shutdown().await?;
460/// # Ok(())
461/// # }
462/// ```
463pub struct HttpClientTransport {
464    /// The base URL of the MCP server endpoint.
465    url: String,
466    /// reqwest HTTP client (reused across requests).
467    client: reqwest::Client,
468    /// Session ID received from the server after `initialize`.
469    session_id: Option<String>,
470    /// Negotiated protocol version.
471    protocol_version: Option<String>,
472    /// Validated custom-header mappings learned from `tools/list`. Replaced
473    /// by a first-page response and extended by later pages.
474    tool_header_mappings: HashMap<String, Vec<CustomHeaderMapping>>,
475    /// Outbound `tools/list` request ids on the 2026-07-28 path, each with
476    /// whether it carried a `cursor`. The response frame names only the id, so
477    /// this is how [`normalize_incoming_message`](Self::normalize_incoming_message)
478    /// tells a later page from the first. Bounded by [`MAX_PENDING_TOOLS_LISTS`].
479    pending_tools_lists: Vec<(serde_json::Value, bool)>,
480    /// Channel receiver for incoming messages (POST responses + SSE events).
481    incoming_rx: mpsc::Receiver<String>,
482    /// Channel sender used by `send()` to queue POST response bodies
483    /// and cloned for the SSE background task.
484    incoming_tx: mpsc::Sender<String>,
485    /// Handle to the SSE background task, if running.
486    sse_task: Option<JoinHandle<()>>,
487    /// In-flight POST response streams, keyed by their JSON-RPC request ID.
488    ///
489    /// Final `subscriptions/listen` requests stay here until cancelled or the
490    /// server closes them. Other completed tasks are pruned on subsequent
491    /// sends.
492    request_tasks: HashMap<RequestId, JoinHandle<()>>,
493    /// The id of the `initialize` request, which the spec forbids cancelling.
494    initialize_request_id: Option<RequestId>,
495    /// The last SSE event ID received, for stream resumption.
496    last_event_id: Arc<RwLock<Option<String>>>,
497    /// Server-requested retry delay from SSE `retry:` field.
498    sse_retry_delay: Arc<RwLock<Option<Duration>>>,
499    /// Signal to tell the SSE loop to close its current stream and reconnect.
500    sse_reconnect_signal: Arc<Notify>,
501    /// Whether the transport is still connected.
502    connected: Arc<AtomicBool>,
503    /// Configuration options.
504    config: HttpClientConfig,
505    /// Dynamic token provider for OAuth or other token-based auth.
506    #[cfg(feature = "oauth-client")]
507    token_provider: Option<Arc<dyn TokenProvider>>,
508    /// Runtime policy for insufficient-scope challenges.
509    #[cfg(feature = "oauth-client")]
510    scope_escalation: Option<ScopeEscalationRuntime>,
511}
512
513impl HttpClientTransport {
514    /// Create a new HTTP client transport targeting the given URL.
515    ///
516    /// Uses default configuration. The URL should be the MCP server's
517    /// Streamable HTTP endpoint (e.g., `http://localhost:3000`).
518    ///
519    /// # Example
520    ///
521    /// ```rust,no_run
522    /// use tower_mcp::client::HttpClientTransport;
523    ///
524    /// let transport = HttpClientTransport::new("http://localhost:3000");
525    /// ```
526    pub fn new(url: impl Into<String>) -> Self {
527        Self::with_config(url, HttpClientConfig::default())
528    }
529
530    /// Create with custom configuration.
531    ///
532    /// # Example
533    ///
534    /// ```rust,no_run
535    /// use tower_mcp::client::{HttpClientTransport, HttpClientConfig};
536    /// use std::time::Duration;
537    ///
538    /// let config = HttpClientConfig {
539    ///     request_timeout: Duration::from_secs(60),
540    ///     sse_reconnect: false,
541    ///     ..Default::default()
542    /// };
543    /// let transport = HttpClientTransport::with_config("http://localhost:3000", config);
544    /// ```
545    pub fn with_config(url: impl Into<String>, config: HttpClientConfig) -> Self {
546        let (tx, rx) = mpsc::channel(config.channel_capacity);
547        Self {
548            url: url.into(),
549            client: build_client(config.follow_cross_origin_redirects),
550            session_id: None,
551            protocol_version: None,
552            tool_header_mappings: HashMap::new(),
553            pending_tools_lists: Vec::new(),
554            incoming_rx: rx,
555            incoming_tx: tx,
556            sse_task: None,
557            request_tasks: HashMap::new(),
558            initialize_request_id: None,
559            last_event_id: Arc::new(RwLock::new(None)),
560            sse_retry_delay: Arc::new(RwLock::new(None)),
561            sse_reconnect_signal: Arc::new(Notify::new()),
562            connected: Arc::new(AtomicBool::new(true)),
563            config,
564            #[cfg(feature = "oauth-client")]
565            token_provider: None,
566            #[cfg(feature = "oauth-client")]
567            scope_escalation: None,
568        }
569    }
570
571    /// Create with an existing `reqwest::Client`.
572    ///
573    /// Use this when you need custom TLS configuration, proxy settings,
574    /// or connection pooling.
575    ///
576    /// # Example
577    ///
578    /// ```rust,no_run
579    /// use tower_mcp::client::HttpClientTransport;
580    ///
581    /// let client = reqwest::Client::builder()
582    ///     .danger_accept_invalid_certs(true) // for development
583    ///     .build()
584    ///     .unwrap();
585    /// let transport = HttpClientTransport::with_client("https://mcp.example.com", client);
586    /// ```
587    pub fn with_client(url: impl Into<String>, client: reqwest::Client) -> Self {
588        let config = HttpClientConfig::default();
589        let (tx, rx) = mpsc::channel(config.channel_capacity);
590        Self {
591            url: url.into(),
592            client,
593            session_id: None,
594            protocol_version: None,
595            tool_header_mappings: HashMap::new(),
596            pending_tools_lists: Vec::new(),
597            incoming_rx: rx,
598            incoming_tx: tx,
599            sse_task: None,
600            request_tasks: HashMap::new(),
601            initialize_request_id: None,
602            last_event_id: Arc::new(RwLock::new(None)),
603            sse_retry_delay: Arc::new(RwLock::new(None)),
604            sse_reconnect_signal: Arc::new(Notify::new()),
605            connected: Arc::new(AtomicBool::new(true)),
606            config,
607            #[cfg(feature = "oauth-client")]
608            token_provider: None,
609            #[cfg(feature = "oauth-client")]
610            scope_escalation: None,
611        }
612    }
613
614    /// Set a Bearer token for `Authorization: Bearer <token>` authentication.
615    ///
616    /// The token is included on every HTTP request (POST and SSE GET).
617    ///
618    /// # Example
619    ///
620    /// ```rust,no_run
621    /// use tower_mcp::client::HttpClientTransport;
622    ///
623    /// let transport = HttpClientTransport::new("http://localhost:3000")
624    ///     .bearer_token("sk-my-secret-token");
625    /// ```
626    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
627        self.config.headers.insert(
628            "Authorization".to_string(),
629            format!("Bearer {}", token.into()),
630        );
631        self
632    }
633
634    /// Set an API key for authentication.
635    ///
636    /// Sends as `Authorization: Bearer <key>`. Use
637    /// [`api_key_header`](Self::api_key_header) for a custom header name.
638    ///
639    /// # Example
640    ///
641    /// ```rust,no_run
642    /// use tower_mcp::client::HttpClientTransport;
643    ///
644    /// let transport = HttpClientTransport::new("http://localhost:3000")
645    ///     .api_key("sk-my-api-key");
646    /// ```
647    pub fn api_key(self, key: impl Into<String>) -> Self {
648        self.bearer_token(key)
649    }
650
651    /// Set an API key using a custom header name.
652    ///
653    /// Sends the key as the raw header value (no `Bearer` prefix).
654    ///
655    /// # Example
656    ///
657    /// ```rust,no_run
658    /// use tower_mcp::client::HttpClientTransport;
659    ///
660    /// let transport = HttpClientTransport::new("http://localhost:3000")
661    ///     .api_key_header("X-API-Key", "sk-my-api-key");
662    /// ```
663    pub fn api_key_header(mut self, name: impl Into<String>, key: impl Into<String>) -> Self {
664        self.config.headers.insert(name.into(), key.into());
665        self
666    }
667
668    /// Set Basic authentication credentials.
669    ///
670    /// Encodes `username:password` as Base64 and sends as
671    /// `Authorization: Basic <encoded>`.
672    ///
673    /// # Example
674    ///
675    /// ```rust,no_run
676    /// use tower_mcp::client::HttpClientTransport;
677    ///
678    /// let transport = HttpClientTransport::new("http://localhost:3000")
679    ///     .basic_auth("admin", "secret");
680    /// ```
681    pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
682        use base64::Engine;
683        let encoded = base64::engine::general_purpose::STANDARD.encode(format!(
684            "{}:{}",
685            username.as_ref(),
686            password.as_ref()
687        ));
688        self.config
689            .headers
690            .insert("Authorization".to_string(), format!("Basic {}", encoded));
691        self
692    }
693
694    /// Add a custom header to every request.
695    ///
696    /// Can be called multiple times to add multiple headers.
697    ///
698    /// # Example
699    ///
700    /// ```rust,no_run
701    /// use tower_mcp::client::HttpClientTransport;
702    ///
703    /// let transport = HttpClientTransport::new("http://localhost:3000")
704    ///     .header("X-Custom-Header", "my-value")
705    ///     .header("X-Request-Source", "my-app");
706    /// ```
707    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
708        self.config.headers.insert(name.into(), value.into());
709        self
710    }
711
712    /// Disable automatic session recovery.
713    ///
714    /// By default, if the server returns a session expired error (HTTP 404
715    /// with session ID or JSON-RPC -32005), the client will automatically
716    /// re-initialize and retry the failed operation. Call this to disable
717    /// that behavior and surface the error to the caller instead.
718    pub fn disable_session_recovery(mut self) -> Self {
719        self.config.session_recovery = false;
720        self
721    }
722
723    /// Set a dynamic token provider for authentication.
724    ///
725    /// The provider's [`TokenProvider::get_token()`] is called before each
726    /// HTTP request, and the returned token is sent as `Authorization: Bearer <token>`.
727    /// This overrides any static `Authorization` header set via [`bearer_token()`](Self::bearer_token)
728    /// or [`basic_auth()`](Self::basic_auth).
729    ///
730    /// Use [`OAuthClientCredentials`](super::OAuthClientCredentials) for
731    /// OAuth 2.0 Client Credentials grants, or implement [`TokenProvider`]
732    /// for custom token acquisition logic.
733    ///
734    /// # Example
735    ///
736    /// ```rust,no_run
737    /// use tower_mcp::client::{HttpClientTransport, OAuthClientCredentials};
738    ///
739    /// # fn example() -> Result<(), tower_mcp::BoxError> {
740    /// let provider = OAuthClientCredentials::builder()
741    ///     .client_id("my-client")
742    ///     .client_secret("my-secret")
743    ///     .token_endpoint("https://auth.example.com/token")
744    ///     .resource("http://localhost:3000")
745    ///     .build()?;
746    ///
747    /// let transport = HttpClientTransport::new("http://localhost:3000")
748    ///     .with_token_provider(provider);
749    /// # Ok(())
750    /// # }
751    /// ```
752    #[cfg(feature = "oauth-client")]
753    pub fn with_token_provider(mut self, provider: impl TokenProvider) -> Self {
754        self.token_provider = Some(Arc::new(provider));
755        self.scope_escalation = None;
756        self
757    }
758
759    /// Set a token provider with bounded runtime scope escalation.
760    ///
761    /// When an MCP operation receives an HTTP 403 Bearer challenge with
762    /// `error="insufficient_scope"`, the transport unions the challenged
763    /// scopes with the scopes already tracked by `config`, invokes
764    /// [`OAuthScopeEscalationHandler::reauthorize`], asks the provider for a
765    /// fresh token, and retries the same operation. Reauthorization is
766    /// serialized across concurrent requests, and each operation is bounded
767    /// by [`OAuthScopeEscalationConfig::maximum_attempts`].
768    ///
769    /// The provider and handler are the same value so the handler can update
770    /// the token returned by [`TokenProvider::get_token`].
771    #[cfg(feature = "oauth-client")]
772    pub fn with_scope_aware_token_provider<P>(
773        mut self,
774        provider: P,
775        config: OAuthScopeEscalationConfig,
776    ) -> Self
777    where
778        P: TokenProvider + OAuthScopeEscalationHandler,
779    {
780        let provider = Arc::new(provider);
781        self.token_provider = Some(provider.clone());
782        self.scope_escalation = Some(ScopeEscalationRuntime::new(provider, config));
783        self
784    }
785
786    fn outgoing_custom_headers(&self, parsed: &serde_json::Value) -> Vec<(String, String)> {
787        if parsed.get("method").and_then(serde_json::Value::as_str) != Some("tools/call") {
788            return Vec::new();
789        }
790        let Some(params) = parsed.get("params") else {
791            return Vec::new();
792        };
793        let Some(name) = params.get("name").and_then(serde_json::Value::as_str) else {
794            return Vec::new();
795        };
796        let Some(mappings) = self.tool_header_mappings.get(name) else {
797            return Vec::new();
798        };
799        let arguments = params.get("arguments").unwrap_or(&serde_json::Value::Null);
800
801        mappings
802            .iter()
803            .filter_map(|mapping| {
804                let value = value_at_property_path(arguments, &mapping.property_path)?;
805                if value.is_null() {
806                    return None;
807                }
808                let rendered = json_value_to_header_string(value)?;
809                Some((
810                    format!("{MCP_PARAM_HEADER_PREFIX}{}", mapping.suffix),
811                    encode_header_value(&rendered),
812                ))
813            })
814            .collect()
815    }
816
817    fn normalize_incoming_message(&mut self, message: String) -> String {
818        if self.protocol_version.as_deref() != Some(crate::protocol::PROTOCOL_VERSION_2026_07_28) {
819            return message;
820        }
821        let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&message) else {
822            return message;
823        };
824        // The server says the tool set changed, so every learned mapping is
825        // suspect. The client re-lists from the first page next.
826        if parsed.get("method").and_then(serde_json::Value::as_str)
827            == Some("notifications/tools/list_changed")
828        {
829            self.tool_header_mappings.clear();
830            return message;
831        }
832        // Take the recorded outbound request, if any, even for a frame that
833        // carries no tools (an error), so the entry does not linger.
834        let later_page = parsed
835            .get("id")
836            .and_then(|id| {
837                let index = self
838                    .pending_tools_lists
839                    .iter()
840                    .position(|(pending, _)| json_request_ids_match(pending, id))?;
841                Some(self.pending_tools_lists.remove(index).1)
842            })
843            .unwrap_or(false);
844        let Some(tools) = parsed
845            .get_mut("result")
846            .and_then(|result| result.get_mut("tools"))
847            .and_then(serde_json::Value::as_array_mut)
848        else {
849            return message;
850        };
851
852        // `list_all_tools` pages, and a later page must add to the mappings
853        // of the earlier ones rather than replace them: a call to a tool from
854        // page one still has to mirror its `Mcp-Param-*` headers.
855        if !later_page {
856            self.tool_header_mappings.clear();
857        }
858        tools.retain(|tool| {
859            let Some(name) = tool.get("name").and_then(serde_json::Value::as_str) else {
860                return false;
861            };
862            let Some(schema) = tool.get("inputSchema") else {
863                return false;
864            };
865            match custom_header_mappings(schema) {
866                Ok(mappings) => {
867                    self.tool_header_mappings.insert(name.to_string(), mappings);
868                    true
869                }
870                Err(error) => {
871                    tracing::warn!(tool = %name, %error, "Excluding tool with invalid x-mcp-header annotations");
872                    false
873                }
874            }
875        });
876
877        parsed.to_string()
878    }
879
880    /// Queue a frame for [`recv`](ClientTransport::recv).
881    async fn queue_incoming(&self, frame: String) -> Result<()> {
882        self.incoming_tx
883            .send(frame)
884            .await
885            .map_err(|_| Error::Transport("Internal channel closed".to_string()))
886    }
887
888    /// Start the SSE background stream after session is established.
889    fn start_sse_stream(&mut self) {
890        let url = self.url.clone();
891        let client = self.client.clone();
892        let session_id = self.session_id.clone().unwrap();
893        let protocol_version = self.protocol_version.clone();
894        let tx = self.incoming_tx.clone();
895        let last_event_id = self.last_event_id.clone();
896        let sse_retry_delay = self.sse_retry_delay.clone();
897        let reconnect_signal = self.sse_reconnect_signal.clone();
898        let connected = self.connected.clone();
899        let config = self.config.clone();
900        #[cfg(feature = "oauth-client")]
901        let token_provider = self.token_provider.clone();
902
903        self.sse_task = Some(tokio::spawn(async move {
904            sse_stream_loop(SseLoopParams {
905                url,
906                client,
907                session_id,
908                protocol_version,
909                tx,
910                last_event_id,
911                sse_retry_delay,
912                reconnect_signal,
913                connected,
914                config,
915                #[cfg(feature = "oauth-client")]
916                token_provider,
917            })
918            .await;
919        }));
920    }
921}
922
923fn custom_header_mappings(
924    schema: &serde_json::Value,
925) -> std::result::Result<Vec<CustomHeaderMapping>, String> {
926    fn annotation_count(value: &serde_json::Value) -> usize {
927        match value {
928            serde_json::Value::Object(object) => {
929                usize::from(object.contains_key("x-mcp-header"))
930                    + object.values().map(annotation_count).sum::<usize>()
931            }
932            serde_json::Value::Array(values) => values.iter().map(annotation_count).sum::<usize>(),
933            _ => 0,
934        }
935    }
936
937    fn is_tchar(byte: u8) -> bool {
938        byte.is_ascii_alphanumeric()
939            || matches!(
940                byte,
941                b'!' | b'#'
942                    | b'$'
943                    | b'%'
944                    | b'&'
945                    | b'\''
946                    | b'*'
947                    | b'+'
948                    | b'-'
949                    | b'.'
950                    | b'^'
951                    | b'_'
952                    | b'`'
953                    | b'|'
954                    | b'~'
955            )
956    }
957
958    // SEP-2243 allows integer, string and boolean; `number` is not permitted.
959    fn primitive_header_type(schema: &serde_json::Value) -> bool {
960        match schema.get("type") {
961            Some(serde_json::Value::String(kind)) => {
962                matches!(kind.as_str(), "string" | "integer" | "boolean")
963            }
964            Some(serde_json::Value::Array(kinds)) => {
965                let mut primitive = false;
966                for kind in kinds {
967                    match kind.as_str() {
968                        Some("string" | "integer" | "boolean") if !primitive => {
969                            primitive = true;
970                        }
971                        Some("null") => {}
972                        _ => return false,
973                    }
974                }
975                primitive
976            }
977            _ => false,
978        }
979    }
980
981    fn walk(
982        schema: &serde_json::Value,
983        path: &mut Vec<String>,
984        seen: &mut std::collections::HashSet<String>,
985        mappings: &mut Vec<CustomHeaderMapping>,
986    ) -> std::result::Result<(), String> {
987        let Some(properties) = schema
988            .get("properties")
989            .and_then(serde_json::Value::as_object)
990        else {
991            return Ok(());
992        };
993        for (property_name, property_schema) in properties {
994            path.push(property_name.clone());
995            if let Some(annotation) = property_schema.get("x-mcp-header") {
996                let suffix = annotation
997                    .as_str()
998                    .ok_or_else(|| format!("annotation at {} is not a string", path.join(".")))?;
999                if suffix.is_empty() || !suffix.bytes().all(is_tchar) {
1000                    return Err(format!(
1001                        "invalid header suffix {suffix:?} at {}",
1002                        path.join(".")
1003                    ));
1004                }
1005                if !primitive_header_type(property_schema) {
1006                    return Err(format!(
1007                        "annotation at {} is not on a primitive property",
1008                        path.join(".")
1009                    ));
1010                }
1011                if !seen.insert(suffix.to_ascii_lowercase()) {
1012                    return Err(format!("duplicate header suffix {suffix:?}"));
1013                }
1014                mappings.push(CustomHeaderMapping {
1015                    suffix: suffix.to_string(),
1016                    property_path: path.clone(),
1017                });
1018            }
1019            walk(property_schema, path, seen, mappings)?;
1020            path.pop();
1021        }
1022        Ok(())
1023    }
1024
1025    let mut mappings = Vec::new();
1026    walk(
1027        schema,
1028        &mut Vec::new(),
1029        &mut std::collections::HashSet::new(),
1030        &mut mappings,
1031    )?;
1032    if annotation_count(schema) != mappings.len() {
1033        return Err(
1034            "x-mcp-header annotation is not statically reachable through properties".to_string(),
1035        );
1036    }
1037    Ok(mappings)
1038}
1039
1040fn value_at_property_path<'a>(
1041    root: &'a serde_json::Value,
1042    path: &[String],
1043) -> Option<&'a serde_json::Value> {
1044    path.iter().try_fold(root, |value, key| value.get(key))
1045}
1046
1047fn json_value_to_header_string(value: &serde_json::Value) -> Option<String> {
1048    match value {
1049        serde_json::Value::String(value) => Some(value.clone()),
1050        serde_json::Value::Number(value) => Some(value.to_string()),
1051        serde_json::Value::Bool(value) => Some(value.to_string()),
1052        serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
1053            None
1054        }
1055    }
1056}
1057
1058fn encode_header_value(value: &str) -> String {
1059    let unsafe_for_header =
1060        value.trim() != value || value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte));
1061    if unsafe_for_header {
1062        format!(
1063            "{BASE64_SENTINEL_PREFIX}{}{BASE64_SENTINEL_SUFFIX}",
1064            base64::engine::general_purpose::STANDARD.encode(value)
1065        )
1066    } else {
1067        value.to_string()
1068    }
1069}
1070
1071#[cfg(feature = "oauth-client")]
1072fn bearer_headers(token: &str) -> std::result::Result<reqwest::header::HeaderMap, String> {
1073    let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
1074        .map_err(|_| "token provider returned an invalid bearer token".to_string())?;
1075    let mut headers = reqwest::header::HeaderMap::new();
1076    headers.insert(reqwest::header::AUTHORIZATION, value);
1077    // RequestBuilder::header appends, which can leave a stale Authorization
1078    // value in front of the fresh token. Supplying a HeaderMap replaces the
1079    // existing value instead.
1080    Ok(headers)
1081}
1082
1083fn is_jsonrpc_error_response(value: &serde_json::Value) -> bool {
1084    value.get("error").is_some_and(serde_json::Value::is_object)
1085        && value.pointer("/error/code").is_some()
1086        && value.pointer("/error/message").is_some()
1087}
1088
1089struct HttpRequestSendError {
1090    message: String,
1091    connection_failed: bool,
1092}
1093
1094// Neither Display, Debug, nor a source chain is safe: reqwest carries the URL,
1095// and a custom redirect error can quote an entirely different URL.
1096fn request_error_message(error: &reqwest::Error) -> String {
1097    let category = if error.is_timeout() {
1098        "timeout"
1099    } else if error.is_redirect() {
1100        "redirect rejected (check redirect policy)"
1101    } else if error.is_connect() {
1102        "connection failed"
1103    } else if error.is_builder() {
1104        "invalid request configuration"
1105    } else if error.is_body() {
1106        "response body I/O failed"
1107    } else if error.is_decode() {
1108        "response decoding failed"
1109    } else {
1110        "request failed"
1111    };
1112    match error.status() {
1113        Some(status) => format!("HTTP request failed: {category} (HTTP {status})"),
1114        None => format!("HTTP request failed: {category}"),
1115    }
1116}
1117
1118impl HttpRequestSendError {
1119    fn request(error: reqwest::Error) -> Self {
1120        Self {
1121            message: request_error_message(&error),
1122            connection_failed: true,
1123        }
1124    }
1125
1126    #[cfg(feature = "oauth-client")]
1127    fn oauth(_error: OAuthClientError) -> Self {
1128        Self {
1129            message: "OAuth token acquisition failed".to_string(),
1130            connection_failed: false,
1131        }
1132    }
1133}
1134
1135#[cfg(not(feature = "oauth-client"))]
1136async fn send_http_request(
1137    request: reqwest::RequestBuilder,
1138    resource: &str,
1139    operation: &str,
1140) -> std::result::Result<reqwest::Response, HttpRequestSendError> {
1141    let _ = (resource, operation);
1142    request.send().await.map_err(HttpRequestSendError::request)
1143}
1144
1145#[cfg(feature = "oauth-client")]
1146async fn send_http_request(
1147    mut request: reqwest::RequestBuilder,
1148    resource: &str,
1149    operation: &str,
1150    token_provider: Option<Arc<dyn TokenProvider>>,
1151    scope_escalation: Option<ScopeEscalationRuntime>,
1152    initial_scope_revision: usize,
1153) -> std::result::Result<reqwest::Response, HttpRequestSendError> {
1154    let mut observed_revision = initial_scope_revision;
1155    let mut attempts = 0;
1156    // Bounded separately from scope escalation, and at one. The two are
1157    // different failures: a scope challenge names what to ask for and can
1158    // legitimately step up more than once, while a rejected token has exactly
1159    // one remedy, and repeating it against a server that keeps saying no is a
1160    // retry loop rather than a recovery (#1370).
1161    let mut reauthenticated = false;
1162
1163    loop {
1164        let retry_request = request.try_clone();
1165
1166        let response = request
1167            .send()
1168            .await
1169            .map_err(HttpRequestSendError::request)?;
1170
1171        // A 401 says the token was rejected whatever the provider's cache
1172        // believes, so the cached value is discarded before asking for
1173        // another. Anything without a Bearer challenge is left alone: a bare
1174        // 401 is the caller's to handle.
1175        if response.status() == reqwest::StatusCode::UNAUTHORIZED
1176            && !reauthenticated
1177            && bearer_challenge(response.headers()).is_some()
1178            && let Some(provider) = token_provider.as_ref()
1179            // Cloned rather than taken: the scope-escalation path below needs
1180            // the original if this branch does not fire.
1181            && let Some(reauth_request) = retry_request.as_ref().and_then(|r| r.try_clone())
1182        {
1183            reauthenticated = true;
1184            provider.invalidate().await;
1185            let token = provider
1186                .get_token()
1187                .await
1188                .map_err(HttpRequestSendError::oauth)?;
1189            let headers = bearer_headers(&token).map_err(|message| {
1190                HttpRequestSendError::oauth(OAuthClientError::ScopeEscalation(message))
1191            })?;
1192            request = reauth_request.headers(headers);
1193            continue;
1194        }
1195
1196        let challenge = if response.status() == reqwest::StatusCode::FORBIDDEN {
1197            scope_challenge(response.headers())
1198        } else {
1199            None
1200        };
1201        let Some(challenge) = challenge else {
1202            return Ok(response);
1203        };
1204        let (Some(runtime), Some(provider), Some(mut retry_request)) = (
1205            scope_escalation.as_ref(),
1206            token_provider.as_ref(),
1207            retry_request,
1208        ) else {
1209            return Ok(response);
1210        };
1211        if attempts >= runtime.max_attempts {
1212            return Ok(response);
1213        }
1214
1215        attempts += 1;
1216        let decision = runtime
1217            .respond_to_challenge(challenge, resource, operation, attempts, observed_revision)
1218            .await
1219            .map_err(HttpRequestSendError::oauth)?;
1220        observed_revision = decision.revision;
1221
1222        let token = provider
1223            .get_token()
1224            .await
1225            .map_err(HttpRequestSendError::oauth)?;
1226        let headers = bearer_headers(&token).map_err(|message| {
1227            HttpRequestSendError::oauth(OAuthClientError::ScopeEscalation(message))
1228        })?;
1229        retry_request = retry_request.headers(headers);
1230        request = retry_request;
1231    }
1232}
1233
1234#[cfg(feature = "oauth-client")]
1235fn scope_challenge(headers: &reqwest::header::HeaderMap) -> Option<OAuthScopeChallenge> {
1236    headers
1237        .get_all(reqwest::header::WWW_AUTHENTICATE)
1238        .iter()
1239        .filter_map(|value| value.to_str().ok())
1240        .find_map(OAuthScopeChallenge::from_www_authenticate)
1241}
1242
1243/// Whether the response carries any Bearer challenge at all.
1244///
1245/// Wider than [`scope_challenge`], deliberately. That one accepts only
1246/// `insufficient_scope` with scopes attached, because a scope escalation needs
1247/// to know what to ask for. A `401` names no scopes and often no error code
1248/// either, and the remedy does not depend on which: the token was rejected, so
1249/// fetch another. Requiring a challenge at all is what separates "the server
1250/// is asking for authentication" from a bare `401` that some intermediary
1251/// produced, which is not ours to retry (#1370).
1252#[cfg(feature = "oauth-client")]
1253fn bearer_challenge(headers: &reqwest::header::HeaderMap) -> Option<OAuthBearerChallenge> {
1254    headers
1255        .get_all(reqwest::header::WWW_AUTHENTICATE)
1256        .iter()
1257        .filter_map(|value| value.to_str().ok())
1258        .find_map(OAuthBearerChallenge::from_www_authenticate)
1259}
1260
1261fn http_status_error(status: reqwest::StatusCode, headers: &reqwest::header::HeaderMap) -> String {
1262    #[cfg(feature = "oauth-client")]
1263    if scope_challenge(headers).is_some() {
1264        return format!("server returned HTTP {status}: insufficient_scope");
1265    }
1266
1267    // A 401 that reached here survived the re-authentication retry, so the
1268    // replacement token was rejected too. Keep known RFC 6750 error codes,
1269    // but never echo descriptions, scope values, or metadata URLs into logs.
1270    // The original challenge remains in structured status data.
1271    #[cfg(feature = "oauth-client")]
1272    if let Some(challenge) = bearer_challenge(headers) {
1273        let detail = match challenge.error.as_deref() {
1274            Some("invalid_request") => "invalid_request",
1275            Some("invalid_token") => "invalid_token",
1276            Some("insufficient_scope") => "insufficient_scope",
1277            _ => "authentication rejected",
1278        };
1279        return format!("server returned HTTP {status}: {detail}");
1280    }
1281
1282    #[cfg(not(feature = "oauth-client"))]
1283    let _ = headers;
1284    // A 301/302/303 reaches here because the redirect policy declines to
1285    // follow it. Only that is reported: the `Location` is a URL, which
1286    // diagnostics leave out.
1287    if matches!(
1288        status,
1289        reqwest::StatusCode::MOVED_PERMANENTLY
1290            | reqwest::StatusCode::FOUND
1291            | reqwest::StatusCode::SEE_OTHER
1292    ) {
1293        return format!(
1294            "server returned HTTP {status}: redirect not followed (only 307 and 308 keep \
1295             the request body; use the final URL)"
1296        );
1297    }
1298    format!("server returned HTTP {status}")
1299}
1300
1301/// Why [`read_bounded_body`] failed to produce a body.
1302enum BoundedBodyError {
1303    /// The body exceeded the configured limit, caught either from a
1304    /// declared `Content-Length` or while streaming. The connection itself
1305    /// is fine: only this one response was oversized.
1306    TooLarge(String),
1307    /// The underlying HTTP stream failed while reading the body.
1308    Io(reqwest::Error),
1309}
1310
1311/// Read a non-SSE response body, rejecting it once its size exceeds
1312/// `limit` instead of buffering an unbounded amount of memory.
1313///
1314/// Checks `Content-Length` up front so a body that declares itself too
1315/// large is rejected before a byte is read, then streams and counts bytes
1316/// so a chunked body (or one served with `Connection: close` and no
1317/// declared length) is bounded too. Decoded as UTF-8 lossily, the same way
1318/// the SSE path in this file decodes bytes (`String::from_utf8_lossy`).
1319async fn read_bounded_body(
1320    response: reqwest::Response,
1321    limit: usize,
1322) -> std::result::Result<String, BoundedBodyError> {
1323    if let Some(len) = response.content_length()
1324        && len > limit as u64
1325    {
1326        return Err(BoundedBodyError::TooLarge(format!(
1327            "response body of {len} bytes exceeds the {limit}-byte limit"
1328        )));
1329    }
1330
1331    use futures::StreamExt;
1332    let mut stream = response.bytes_stream();
1333    let mut buf = Vec::new();
1334    while let Some(chunk) = stream.next().await {
1335        let chunk = chunk.map_err(BoundedBodyError::Io)?;
1336        buf.extend_from_slice(&chunk);
1337        if buf.len() > limit {
1338            return Err(BoundedBodyError::TooLarge(format!(
1339                "response body exceeds the {limit}-byte limit"
1340            )));
1341        }
1342    }
1343    Ok(String::from_utf8_lossy(&buf).into_owned())
1344}
1345
1346/// Maximum bytes read for an HTTP error body before truncating it.
1347///
1348/// Error bodies are read to recognize MCP JSON-RPC error frames. An oversized
1349/// or unrecognized body falls back to status-only diagnostics; raw bodies are
1350/// never quoted in transport-generated errors or logs.
1351const MAX_ERROR_BODY_SIZE: usize = 64 * 1024;
1352
1353/// Read a non-2xx response body up to [`MAX_ERROR_BODY_SIZE`], truncating
1354/// rather than failing the request if the server sends more.
1355async fn read_error_body(response: reqwest::Response) -> String {
1356    use futures::StreamExt;
1357    let mut stream = response.bytes_stream();
1358    let mut buf = Vec::new();
1359    while let Some(chunk) = stream.next().await {
1360        let Ok(chunk) = chunk else { break };
1361        buf.extend_from_slice(&chunk);
1362        if buf.len() > MAX_ERROR_BODY_SIZE {
1363            buf.truncate(MAX_ERROR_BODY_SIZE);
1364            break;
1365        }
1366    }
1367    String::from_utf8_lossy(&buf).into_owned()
1368}
1369
1370fn operation_label(parsed: Option<&serde_json::Value>) -> String {
1371    let Some(method) = parsed
1372        .and_then(|value| value.get("method"))
1373        .and_then(serde_json::Value::as_str)
1374    else {
1375        return "unknown".to_string();
1376    };
1377    let target = match method {
1378        "tools/call" | "prompts/get" => parsed
1379            .and_then(|value| value.pointer("/params/name"))
1380            .and_then(serde_json::Value::as_str),
1381        "resources/read" => parsed
1382            .and_then(|value| value.pointer("/params/uri"))
1383            .and_then(serde_json::Value::as_str),
1384        "tasks/get" | "tasks/update" | "tasks/cancel" => parsed
1385            .and_then(|value| value.pointer("/params/taskId"))
1386            .and_then(serde_json::Value::as_str),
1387        _ => None,
1388    };
1389    match target {
1390        Some(target) => format!("{method}:{target}"),
1391        None => method.to_string(),
1392    }
1393}
1394
1395#[async_trait]
1396impl ClientTransport for HttpClientTransport {
1397    async fn send(&mut self, message: &str) -> Result<()> {
1398        if !self.connected.load(Ordering::Acquire) {
1399            return Err(Error::Transport("Transport closed".to_string()));
1400        }
1401
1402        // Notifications (frames without an `id`) are awaited inline (below) to
1403        // keep `notifications/initialized` ordered before the first request
1404        // (#967). That inline await blocks the whole message loop, so it must
1405        // be bounded independently: a server that stalls the notification's
1406        // 202 (observed: a multi-instance server holding the POST for the full
1407        // request timeout) would otherwise freeze the client with no output.
1408        let parsed_message = serde_json::from_str::<serde_json::Value>(message).ok();
1409        let is_notification = parsed_message
1410            .as_ref()
1411            .map(|v| v.get("id").is_none())
1412            .unwrap_or(false);
1413        let method = parsed_message
1414            .as_ref()
1415            .and_then(|value| value.get("method"))
1416            .and_then(serde_json::Value::as_str);
1417        // Only a request (a frame with both `method` and `id`) is owed a
1418        // response. The client's replies to server requests also pass through
1419        // here, and must not have error frames injected on their behalf.
1420        let reply_id = parsed_message
1421            .as_ref()
1422            .filter(|value| value.get("method").is_some())
1423            .and_then(|value| value.get("id"))
1424            .cloned();
1425        let operation = operation_label(parsed_message.as_ref());
1426        if method == Some("initialize") {
1427            self.initialize_request_id = reply_id
1428                .clone()
1429                .and_then(|value| serde_json::from_value(value).ok());
1430        }
1431        let outbound_version = parsed_message
1432            .as_ref()
1433            .and_then(|value| {
1434                value.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion")
1435            })
1436            .and_then(serde_json::Value::as_str)
1437            .map(str::to_string);
1438        let is_modern_request =
1439            outbound_version.as_deref() == Some(crate::protocol::PROTOCOL_VERSION_2026_07_28);
1440        if is_modern_request {
1441            self.protocol_version = outbound_version.clone();
1442            // Final requests are sessionless even when a transitional peer
1443            // incorrectly returns a legacy session header from discovery.
1444            self.session_id = None;
1445        }
1446        if is_modern_request
1447            && method == Some("tools/list")
1448            && let Some(id) = reply_id.as_ref()
1449        {
1450            let has_cursor = parsed_message
1451                .as_ref()
1452                .and_then(|value| value.pointer("/params/cursor"))
1453                .is_some_and(|cursor| !cursor.is_null());
1454            if self.pending_tools_lists.len() >= MAX_PENDING_TOOLS_LISTS {
1455                self.pending_tools_lists.remove(0);
1456            }
1457            self.pending_tools_lists.push((id.clone(), has_cursor));
1458        }
1459        let timeout = if is_notification {
1460            self.config
1461                .notification_timeout
1462                .min(self.config.request_timeout)
1463        } else {
1464            self.config.request_timeout
1465        };
1466
1467        // Build request with headers
1468        let mut request = self
1469            .client
1470            .post(&self.url)
1471            .header("Content-Type", "application/json")
1472            .header("Accept", "application/json, text/event-stream");
1473        // A subscription is intentionally long-lived; its handle owns the
1474        // timeout/cancellation decision. Every ordinary request remains
1475        // bounded by the configured request timeout.
1476        if method != Some("subscriptions/listen") {
1477            request = request.timeout(timeout);
1478        }
1479
1480        if !is_modern_request && let Some(ref session_id) = self.session_id {
1481            request = request.header("mcp-session-id", session_id);
1482        }
1483
1484        if let Some(version) = outbound_version.as_ref().or(self.protocol_version.as_ref()) {
1485            request = request.header("mcp-protocol-version", version);
1486        }
1487
1488        if let Some(method) = method {
1489            request = request.header(MCP_METHOD_HEADER, method);
1490            let name = match method {
1491                "tools/call" | "prompts/get" => parsed_message
1492                    .as_ref()
1493                    .and_then(|value| value.pointer("/params/name"))
1494                    .and_then(serde_json::Value::as_str),
1495                "resources/read" => parsed_message
1496                    .as_ref()
1497                    .and_then(|value| value.pointer("/params/uri"))
1498                    .and_then(serde_json::Value::as_str),
1499                "tasks/get" | "tasks/update" | "tasks/cancel" => parsed_message
1500                    .as_ref()
1501                    .and_then(|value| value.pointer("/params/taskId"))
1502                    .and_then(serde_json::Value::as_str),
1503                _ => None,
1504            };
1505            if let Some(name) = name {
1506                request = request.header(MCP_NAME_HEADER, name);
1507            }
1508        }
1509
1510        if let Some(parsed) = parsed_message.as_ref() {
1511            for (name, value) in self.outgoing_custom_headers(parsed) {
1512                request = request.header(name, value);
1513            }
1514        }
1515
1516        for (key, value) in &self.config.headers {
1517            request = request.header(key.as_str(), value.as_str());
1518        }
1519
1520        #[cfg(feature = "oauth-client")]
1521        let initial_scope_revision = match &self.scope_escalation {
1522            Some(runtime) => runtime.state.lock().await.revision,
1523            None => 0,
1524        };
1525
1526        // Dynamic token provider overrides static Authorization header
1527        #[cfg(feature = "oauth-client")]
1528        if let Some(ref provider) = self.token_provider {
1529            let token = provider
1530                .get_token()
1531                .await
1532                .map_err(|_| Error::Transport("Token provider failed".to_string()))?;
1533            request = request.headers(bearer_headers(&token).map_err(Error::Transport)?);
1534        }
1535
1536        let request = request.body(message.to_string());
1537
1538        // Notifications are awaited inline (bounded above) even after session
1539        // establishment, so `notifications/initialized` reaches the server
1540        // ahead of the first request rather than racing it on a pooled
1541        // connection, which strict servers rejected (#967).
1542        //
1543        // After session is established, send requests in a background task
1544        // so the message loop can continue processing incoming SSE messages.
1545        // This prevents a deadlock when the server blocks on a
1546        // bidirectional request (sampling/elicitation) that requires the
1547        // client to handle a request on the originating POST response stream.
1548        if !is_notification && (self.session_id.is_some() || is_modern_request) {
1549            let tx = self.incoming_tx.clone();
1550            // The caller is parked on this request id in the message loop's
1551            // correlation map. Every failure branch below delivers a frame
1552            // carrying it, so a background POST that dies (network error,
1553            // timeout, HTTP error, empty body, response stream closed early)
1554            // wakes the caller with an error instead of hanging it forever.
1555            //
1556            // A response (the client's reply to a server-initiated request)
1557            // also takes this path, to keep the message loop non-blocking
1558            // (#967), but `reply_id` is `None` for it: it is not tracked in
1559            // `request_tasks` below, and its failure branches log instead of
1560            // injecting an error frame, since the id belongs to the server's
1561            // request, not one of ours (#1466).
1562            let request_id = reply_id
1563                .clone()
1564                .and_then(|value| serde_json::from_value(value).ok());
1565            let is_subscription = method == Some("subscriptions/listen");
1566            // Whether this request carried `Mcp-Session-Id`: a 404 then means
1567            // the session expired (#1556). A 2026-07-28 request is sessionless,
1568            // and there a 404 is an ordinary status (an unknown method answers
1569            // 404 with -32601).
1570            let session_expiry_on_404 = !is_modern_request
1571                && self.session_id.is_some()
1572                && self.config.session_recovery
1573                && reply_id.is_some();
1574            let reply_id = reply_id.clone();
1575            let connected = self.connected.clone();
1576            let last_event_id = self.last_event_id.clone();
1577            let sse_retry_delay = self.sse_retry_delay.clone();
1578            let sse_reconnect_signal = self.sse_reconnect_signal.clone();
1579            let max_sse_event_size = self.config.max_sse_event_size;
1580            let max_response_body_size = self.config.max_response_body_size;
1581            let request_resource = self.url.clone();
1582            #[cfg(feature = "oauth-client")]
1583            let token_provider = self.token_provider.clone();
1584            #[cfg(feature = "oauth-client")]
1585            let scope_escalation = self.scope_escalation.clone();
1586            self.request_tasks.retain(|_, task| !task.is_finished());
1587            let task = tokio::spawn(async move {
1588                let response_result = send_http_request(
1589                    request,
1590                    &request_resource,
1591                    &operation,
1592                    #[cfg(feature = "oauth-client")]
1593                    token_provider,
1594                    #[cfg(feature = "oauth-client")]
1595                    scope_escalation,
1596                    #[cfg(feature = "oauth-client")]
1597                    initial_scope_revision,
1598                )
1599                .await;
1600                let response = match response_result {
1601                    Ok(r) => r,
1602                    Err(e) => {
1603                        let connection_failed = e.connection_failed;
1604                        tracing::error!(error = %e.message, "Background HTTP request failed");
1605                        if let Some(id) = &reply_id {
1606                            let _ = tx.send(transport_error_frame(id, &e.message)).await;
1607                        }
1608                        if connection_failed {
1609                            connected.store(false, Ordering::Release);
1610                        }
1611                        return;
1612                    }
1613                };
1614
1615                let status = response.status();
1616
1617                // 202 Accepted acknowledges a notification or a reply, never a
1618                // request, which is owed a JSON-RPC response.
1619                if status == reqwest::StatusCode::ACCEPTED {
1620                    if let Some(id) = &reply_id {
1621                        let _ = tx
1622                            .send(transport_error_frame(id, ACCEPTED_WITHOUT_RESPONSE))
1623                            .await;
1624                    }
1625                    return;
1626                }
1627
1628                if !status.is_success() {
1629                    let status_error = http_status_error(status, response.headers());
1630                    let retry_after = retry_after_header(response.headers());
1631                    let www_authenticate = www_authenticate_header(response.headers());
1632                    let body = read_error_body(response).await;
1633
1634                    // The spec's session-expired signal is the 404 itself, so
1635                    // it triggers recovery whatever the body holds: nothing, a
1636                    // -32005, or another code such as the -32001 other SDKs
1637                    // use. Hand the message loop the -32005 frame it already
1638                    // recovers on, and leave the transport open for the
1639                    // re-initialize.
1640                    if status == reqwest::StatusCode::NOT_FOUND
1641                        && session_expiry_on_404
1642                        && let Some(id) = &reply_id
1643                    {
1644                        tracing::info!("HTTP 404 on a session request, signalling session expiry");
1645                        let _ = tx.send(session_expired_frame(id)).await;
1646                        return;
1647                    }
1648
1649                    // Forward a JSON-RPC error body so the message loop can
1650                    // detect -32005 (SessionNotFound) and trigger session
1651                    // recovery. If the server did not echo our request id (a
1652                    // null, absent, or foreign id such as a gateway's
1653                    // "server-error", on anything but the session-level
1654                    // -32005 signal), inject it so the awaiting caller is
1655                    // woken by this error instead of hanging. A response has
1656                    // no `reply_id`: its error body is forwarded only for the
1657                    // session signal, since any id it carries is the server's
1658                    // and could match one of the client's own pending
1659                    // requests.
1660                    if !body.is_empty()
1661                        && let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&body)
1662                        && is_jsonrpc_error_response(&v)
1663                    {
1664                        let is_session_signal =
1665                            v.pointer("/error/code").and_then(|c| c.as_i64()) == Some(-32005);
1666                        if reply_id.is_none() && !is_session_signal {
1667                            tracing::warn!(status = %status, "reply POST returned a JSON-RPC error body");
1668                            return;
1669                        }
1670                        if !is_session_signal
1671                            && let Some(id) = &reply_id
1672                            && !v
1673                                .get("id")
1674                                .is_some_and(|actual| json_request_ids_match(actual, id))
1675                        {
1676                            v["id"] = id.clone();
1677                        }
1678                        if !is_session_signal && status_annotates_jsonrpc_error(status) {
1679                            merge_status_into_error(
1680                                &mut v,
1681                                status_error_data(status, &retry_after, &www_authenticate),
1682                            );
1683                        }
1684                        let _ = tx.send(v.to_string()).await;
1685                        return;
1686                    }
1687
1688                    tracing::error!(status = %status, "HTTP error from server");
1689                    if let Some(id) = &reply_id {
1690                        let _ = tx
1691                            .send(transport_error_frame_with_data(
1692                                id,
1693                                &status_error,
1694                                status_error_data(status, &retry_after, &www_authenticate),
1695                            ))
1696                            .await;
1697                    }
1698                    // Only a status that says the endpoint is gone ends the
1699                    // transport. Anything else (a 429 from a rate limiter, a
1700                    // 502 or 503 from a gateway) failed this request alone
1701                    // and the next one may well succeed (#1553).
1702                    if status_ends_transport(status) {
1703                        connected.store(false, Ordering::Release);
1704                    }
1705                    return;
1706                }
1707
1708                // Check if response is SSE-formatted
1709                let is_sse = response
1710                    .headers()
1711                    .get("content-type")
1712                    .and_then(|v| v.to_str().ok())
1713                    .is_some_and(|ct| ct.contains("text/event-stream"));
1714
1715                if is_sse {
1716                    pump_post_sse(
1717                        response,
1718                        PostSseContext {
1719                            tx,
1720                            reply_id,
1721                            is_subscription,
1722                            is_modern_request,
1723                            connected,
1724                            last_event_id,
1725                            sse_retry_delay,
1726                            sse_reconnect_signal,
1727                            max_sse_event_size,
1728                        },
1729                    )
1730                    .await;
1731                } else {
1732                    // Non-SSE response: read body (bounded) and queue for recv().
1733                    match read_bounded_body(response, max_response_body_size).await {
1734                        Ok(body) if !body.is_empty() => {
1735                            let msgs = extract_json_messages(&body);
1736                            if msgs.is_empty() {
1737                                // A non-empty body that yields no JSON-RPC
1738                                // frames leaves the request uncorrelated. A
1739                                // response has no client request to wake;
1740                                // log instead.
1741                                if let Some(id) = &reply_id {
1742                                    let _ = tx
1743                                        .send(transport_error_frame(
1744                                            id,
1745                                            "server returned an unparseable response body",
1746                                        ))
1747                                        .await;
1748                                } else {
1749                                    tracing::warn!(
1750                                        "reply POST returned an unparseable response body"
1751                                    );
1752                                }
1753                            } else {
1754                                // A `{}` or non-JSON body still yields one
1755                                // "frame", which the message loop drops.
1756                                let answered = reply_id
1757                                    .as_ref()
1758                                    .is_none_or(|id| frames_answer_request(&msgs, id));
1759                                for msg in msgs {
1760                                    let _ = tx.send(msg).await;
1761                                }
1762                                if !answered && let Some(id) = &reply_id {
1763                                    let _ = tx
1764                                        .send(transport_error_frame(id, NO_RESPONSE_IN_BODY))
1765                                        .await;
1766                                }
1767                            }
1768                        }
1769                        Ok(_) => {
1770                            // 2xx with an empty body: no frame to correlate the
1771                            // request, so wake the caller rather than hang. A
1772                            // response has no client request to wake; log
1773                            // instead.
1774                            if let Some(id) = &reply_id {
1775                                let _ = tx
1776                                    .send(transport_error_frame(
1777                                        id,
1778                                        "server returned an empty response body",
1779                                    ))
1780                                    .await;
1781                            } else {
1782                                tracing::warn!("reply POST returned an empty response body");
1783                            }
1784                        }
1785                        Err(BoundedBodyError::TooLarge(message)) => {
1786                            // The connection itself is fine, only this
1787                            // reply was oversized: don't mark it
1788                            // disconnected. A response has no client
1789                            // request to wake; log instead.
1790                            tracing::error!(
1791                                error = %message,
1792                                "Background HTTP response body exceeded the size limit"
1793                            );
1794                            if let Some(id) = &reply_id {
1795                                let _ = tx.send(transport_error_frame(id, &message)).await;
1796                            }
1797                        }
1798                        Err(BoundedBodyError::Io(e)) => {
1799                            let message = request_error_message(&e);
1800                            tracing::error!(error = %message, "Failed to read response body");
1801                            if let Some(id) = &reply_id {
1802                                let _ = tx
1803                                    .send(transport_error_frame(
1804                                        id,
1805                                        &format!("failed to read response body: {message}"),
1806                                    ))
1807                                    .await;
1808                            }
1809                            connected.store(false, Ordering::Release);
1810                        }
1811                    }
1812                }
1813            });
1814            if let Some(request_id) = request_id {
1815                self.request_tasks.insert(request_id, task);
1816            }
1817            return Ok(());
1818        }
1819
1820        // Pre-session (initialize) and notifications: handle synchronously.
1821        // For initialize this extracts session headers and starts the SSE
1822        // stream; for notifications the expected response is a bare 202.
1823        let response = send_http_request(
1824            request,
1825            &self.url,
1826            &operation,
1827            #[cfg(feature = "oauth-client")]
1828            self.token_provider.clone(),
1829            #[cfg(feature = "oauth-client")]
1830            self.scope_escalation.clone(),
1831            #[cfg(feature = "oauth-client")]
1832            initial_scope_revision,
1833        )
1834        .await
1835        .map_err(|e| Error::Transport(e.message))?;
1836
1837        let status = response.status();
1838
1839        // Extract session headers before consuming the body
1840        let new_session_id = response
1841            .headers()
1842            .get("mcp-session-id")
1843            .and_then(|v| v.to_str().ok())
1844            .map(|s| s.to_string());
1845        let new_protocol_version = response
1846            .headers()
1847            .get("mcp-protocol-version")
1848            .and_then(|v| v.to_str().ok())
1849            .map(|s| s.to_string());
1850
1851        // 202 Accepted = notification acknowledged, no body
1852        if status == reqwest::StatusCode::ACCEPTED {
1853            // Still update session state if headers present
1854            if !is_modern_request && let Some(sid) = new_session_id {
1855                self.session_id = Some(sid);
1856            }
1857            if let Some(pv) = new_protocol_version {
1858                self.protocol_version = Some(pv);
1859            }
1860            // A request is owed a response; a 202 leaves its caller waiting.
1861            if let Some(id) = &reply_id {
1862                self.queue_incoming(transport_error_frame(id, ACCEPTED_WITHOUT_RESPONSE))
1863                    .await?;
1864            }
1865            return Ok(());
1866        }
1867
1868        if !status.is_success() {
1869            let status_error = http_status_error(status, response.headers());
1870            let body = read_error_body(response).await;
1871            if is_modern_request
1872                && let Ok(mut error) = serde_json::from_str::<serde_json::Value>(&body)
1873                && is_jsonrpc_error_response(&error)
1874            {
1875                if let Some(id) = parsed_message.as_ref().and_then(|value| value.get("id"))
1876                    && !error
1877                        .get("id")
1878                        .is_some_and(|actual| json_request_ids_match(actual, id))
1879                {
1880                    error["id"] = id.clone();
1881                }
1882                self.queue_incoming(error.to_string()).await?;
1883                return Ok(());
1884            }
1885            // 404 only signals an expired session once a session exists.
1886            // Before that (the initial `initialize`), a 404 means the URL is
1887            // wrong, and reporting it as "Session expired" sends users
1888            // hunting session bugs instead of checking the endpoint path.
1889            if status == reqwest::StatusCode::NOT_FOUND
1890                && self.config.session_recovery
1891                && self.session_id.is_some()
1892            {
1893                return Err(Error::SessionExpired);
1894            }
1895            if status == reqwest::StatusCode::NOT_FOUND && self.session_id.is_none() {
1896                return Err(Error::Transport(
1897                    "HTTP 404: MCP endpoint not found (check the endpoint path; \
1898                     some servers serve MCP at the root, others at /mcp)"
1899                        .to_string(),
1900                ));
1901            }
1902            return Err(Error::Transport(status_error));
1903        }
1904
1905        // Update session state
1906        if !is_modern_request && let Some(sid) = new_session_id {
1907            let is_new_session = self.session_id.is_none();
1908            self.session_id = Some(sid);
1909
1910            if is_new_session && self.config.auto_sse {
1911                self.start_sse_stream();
1912            }
1913        }
1914        if let Some(pv) = new_protocol_version {
1915            self.protocol_version = Some(pv);
1916        }
1917
1918        // A request answered with an SSE stream is read on its own task, as on
1919        // the session path, instead of to EOF here. The server may send a
1920        // request on that stream (`initialize` answered by a ping, say) and
1921        // hold the final reply until it is answered, and the message loop
1922        // that answers it is the caller of this method (#1558).
1923        let is_sse = response
1924            .headers()
1925            .get("content-type")
1926            .and_then(|v| v.to_str().ok())
1927            .is_some_and(|ct| ct.contains("text/event-stream"));
1928        if is_sse && reply_id.is_some() {
1929            let request_id = reply_id
1930                .clone()
1931                .and_then(|value| serde_json::from_value(value).ok());
1932            self.request_tasks.retain(|_, task| !task.is_finished());
1933            let task = tokio::spawn(pump_post_sse(
1934                response,
1935                PostSseContext {
1936                    tx: self.incoming_tx.clone(),
1937                    reply_id,
1938                    is_subscription: false,
1939                    is_modern_request,
1940                    connected: self.connected.clone(),
1941                    last_event_id: self.last_event_id.clone(),
1942                    sse_retry_delay: self.sse_retry_delay.clone(),
1943                    sse_reconnect_signal: self.sse_reconnect_signal.clone(),
1944                    max_sse_event_size: self.config.max_sse_event_size,
1945                },
1946            ));
1947            if let Some(request_id) = request_id {
1948                self.request_tasks.insert(request_id, task);
1949            }
1950            return Ok(());
1951        }
1952
1953        // Read response body (bounded) and queue for recv()
1954        let body = match read_bounded_body(response, self.config.max_response_body_size).await {
1955            Ok(body) => body,
1956            Err(BoundedBodyError::TooLarge(message)) => {
1957                // The connection itself is fine, only this reply was
1958                // oversized: queue the error like #1465's fix does instead
1959                // of failing the transport, so the caller sees a transport
1960                // error rather than hanging.
1961                if let Some(id) = &reply_id {
1962                    self.queue_incoming(transport_error_frame(id, &message))
1963                        .await?;
1964                }
1965                return Ok(());
1966            }
1967            Err(BoundedBodyError::Io(e)) => {
1968                return Err(Error::Transport(format!(
1969                    "Failed to read response: {}",
1970                    request_error_message(&e)
1971                )));
1972            }
1973        };
1974
1975        let msgs = extract_json_messages(&body);
1976        let answered = reply_id
1977            .as_ref()
1978            .is_none_or(|id| frames_answer_request(&msgs, id));
1979        let no_reply = if msgs.is_empty() {
1980            "server returned an empty response body"
1981        } else {
1982            NO_RESPONSE_IN_BODY
1983        };
1984        for msg in msgs {
1985            self.queue_incoming(msg).await?;
1986        }
1987        // Wake the caller rather than leave it parked on a reply that is not
1988        // coming. The transport itself is still healthy.
1989        if !answered && let Some(id) = &reply_id {
1990            self.queue_incoming(transport_error_frame(id, no_reply))
1991                .await?;
1992        }
1993
1994        Ok(())
1995    }
1996
1997    async fn recv(&mut self) -> Result<Option<String>> {
1998        match self.incoming_rx.recv().await {
1999            // All response paths converge here, including background final
2000            // POSTs. Normalize tools/list results on the transport-owning
2001            // task so validated x-mcp-header mappings are available to the
2002            // next tools/call without sharing mutable state across tasks.
2003            Some(msg) => Ok(Some(self.normalize_incoming_message(msg))),
2004            None => {
2005                self.connected.store(false, Ordering::Release);
2006                Ok(None)
2007            }
2008        }
2009    }
2010
2011    fn is_connected(&self) -> bool {
2012        self.connected.load(Ordering::Acquire)
2013    }
2014
2015    async fn close(&mut self) -> Result<()> {
2016        self.connected.store(false, Ordering::Release);
2017
2018        for (_, task) in self.request_tasks.drain() {
2019            task.abort();
2020        }
2021
2022        // Abort SSE task
2023        if let Some(task) = self.sse_task.take() {
2024            task.abort();
2025        }
2026
2027        // Send DELETE to terminate the session (best effort). Clearing
2028        // `session_id` up front, regardless of outcome, is what makes `Drop`
2029        // a no-op if it runs afterward.
2030        if let Some(session_id) = self.session_id.take() {
2031            send_session_delete(SessionDeleteParams {
2032                client: self.client.clone(),
2033                url: self.url.clone(),
2034                session_id,
2035                headers: self.config.headers.clone(),
2036                #[cfg(feature = "oauth-client")]
2037                token_provider: self.token_provider.clone(),
2038            })
2039            .await;
2040        }
2041
2042        Ok(())
2043    }
2044
2045    async fn reset_session(&mut self) {
2046        tracing::info!("Resetting session for re-initialization");
2047
2048        for (_, task) in self.request_tasks.drain() {
2049            task.abort();
2050        }
2051
2052        // Abort SSE task
2053        if let Some(task) = self.sse_task.take() {
2054            task.abort();
2055        }
2056
2057        // Clear session state but keep the transport alive
2058        self.session_id = None;
2059        self.protocol_version = None;
2060        *self.last_event_id.write().await = None;
2061        *self.sse_retry_delay.write().await = None;
2062
2063        // Drain any stale messages from the channel
2064        while self.incoming_rx.try_recv().is_ok() {}
2065    }
2066
2067    async fn end_session(&mut self) {
2068        if let Some(session_id) = self.session_id.clone() {
2069            send_session_delete(SessionDeleteParams {
2070                client: self.client.clone(),
2071                url: self.url.clone(),
2072                session_id,
2073                headers: self.config.headers.clone(),
2074                #[cfg(feature = "oauth-client")]
2075                token_provider: self.token_provider.clone(),
2076            })
2077            .await;
2078        }
2079        self.reset_session().await;
2080    }
2081
2082    fn supports_session_recovery(&self) -> bool {
2083        self.config.session_recovery
2084    }
2085
2086    async fn cancel_request(&mut self, request_id: &RequestId) -> Result<()> {
2087        let mut in_flight = false;
2088        if let Some(task) = self.request_tasks.remove(request_id) {
2089            // Dropping reqwest's response byte stream closes this request's
2090            // HTTP response body, which is the final protocol's cancellation
2091            // signal. Other concurrent POST streams remain alive.
2092            in_flight = !task.is_finished();
2093            task.abort();
2094            let _ = task.await;
2095        }
2096
2097        // On a 2025-11-25 session a closed connection is not a cancellation,
2098        // so the server keeps working unless it is told. The session id is
2099        // only held on that path; the final protocol is sessionless. The
2100        // spec forbids cancelling `initialize`.
2101        if in_flight
2102            && self.session_id.is_some()
2103            && self.initialize_request_id.as_ref() != Some(request_id)
2104        {
2105            let notification = serde_json::json!({
2106                "jsonrpc": "2.0",
2107                "method": "notifications/cancelled",
2108                "params": { "requestId": request_id },
2109            });
2110            // The POST is already aborted, so a failed notification leaves
2111            // nothing to retry; report success so the request is not kept
2112            // pending for a response that can no longer arrive.
2113            if let Err(error) = self.send(&notification.to_string()).await {
2114                tracing::warn!(%error, "Failed to send notifications/cancelled");
2115            }
2116        }
2117        Ok(())
2118    }
2119}
2120
2121/// Best-effort teardown when a transport is dropped without an explicit
2122/// `close()`.
2123///
2124/// Aborts every in-flight task the transport owns. `close()` already drains
2125/// `request_tasks` and takes `sse_task`, so if `close()` ran first this is a
2126/// no-op; the fields are already empty. Also sends the session-termination
2127/// `DELETE`, spawned rather than awaited since `Drop::drop` cannot be async,
2128/// and only when a Tokio runtime is reachable from the current thread.
2129impl Drop for HttpClientTransport {
2130    fn drop(&mut self) {
2131        for (_, task) in self.request_tasks.drain() {
2132            task.abort();
2133        }
2134        if let Some(task) = self.sse_task.take() {
2135            task.abort();
2136        }
2137
2138        // `session_id` is only `Some` for an established legacy (2025-11-25)
2139        // session; the 2026-07-28 stateless path never sets it (see
2140        // `is_modern_request` above), and `close()` clears it once its own
2141        // DELETE has been sent, which keeps this idempotent with `close()`.
2142        let Some(session_id) = self.session_id.take() else {
2143            return;
2144        };
2145        // Dropping outside a runtime (e.g. a transport built but never
2146        // connected) must not panic; there is nowhere to spawn the request.
2147        let Ok(handle) = tokio::runtime::Handle::try_current() else {
2148            return;
2149        };
2150
2151        let params = SessionDeleteParams {
2152            client: self.client.clone(),
2153            url: self.url.clone(),
2154            session_id,
2155            headers: self.config.headers.clone(),
2156            #[cfg(feature = "oauth-client")]
2157            token_provider: self.token_provider.clone(),
2158        };
2159        handle.spawn(send_session_delete(params));
2160    }
2161}
2162
2163// =============================================================================
2164// POST Response Streams
2165// =============================================================================
2166
2167/// What [`pump_post_sse`] needs from the transport that spawned it.
2168struct PostSseContext {
2169    tx: mpsc::Sender<String>,
2170    /// The id of the request this stream answers, or `None` for a reply to a
2171    /// server request, which is owed nothing.
2172    reply_id: Option<serde_json::Value>,
2173    is_subscription: bool,
2174    is_modern_request: bool,
2175    connected: Arc<AtomicBool>,
2176    last_event_id: Arc<RwLock<Option<String>>>,
2177    sse_retry_delay: Arc<RwLock<Option<Duration>>>,
2178    sse_reconnect_signal: Arc<Notify>,
2179    max_sse_event_size: usize,
2180}
2181
2182/// Forward the events of a POST's SSE response to the message loop as they
2183/// arrive, until the request's final reply or the end of the stream.
2184///
2185/// Every frame is queued as soon as its event is complete, so the client can
2186/// answer a server request sent on this stream (ping, sampling, elicitation)
2187/// before the final reply, which the server may be holding until it has.
2188/// A stream that ends without that reply wakes the caller with an error.
2189async fn pump_post_sse(response: reqwest::Response, ctx: PostSseContext) {
2190    let PostSseContext {
2191        tx,
2192        reply_id,
2193        is_subscription,
2194        is_modern_request,
2195        connected,
2196        last_event_id,
2197        sse_retry_delay,
2198        sse_reconnect_signal,
2199        max_sse_event_size,
2200    } = ctx;
2201
2202    // Stream SSE response to extract id/retry fields
2203    let mut stream = response.bytes_stream();
2204    let mut parser = SseParser::with_limit(max_sse_event_size);
2205    let mut had_retry = false;
2206    let mut had_data = false;
2207    let mut subscription_acknowledged = false;
2208
2209    use futures::StreamExt;
2210    while let Some(result) = stream.next().await {
2211        match result {
2212            Ok(bytes) => {
2213                let text = String::from_utf8_lossy(&bytes);
2214                let events = match parser.feed(&text) {
2215                    Ok(events) => events,
2216                    Err(e) => {
2217                        // A single event exceeded the cap;
2218                        // terminate the stream instead of
2219                        // buffering without bound. The
2220                        // response for this request is lost,
2221                        // so the transport is unusable.
2222                        tracing::error!(error = %e, "POST SSE stream terminated");
2223                        connected.store(false, Ordering::Release);
2224                        return;
2225                    }
2226                };
2227                for event in events {
2228                    if let Some(ref id) = event.id {
2229                        *last_event_id.write().await = Some(id.clone());
2230                    }
2231                    if let Some(retry_ms) = event.retry {
2232                        *sse_retry_delay.write().await = Some(Duration::from_millis(retry_ms));
2233                        had_retry = true;
2234                    }
2235                    if !event.data.is_empty() {
2236                        had_data = true;
2237                        let value = serde_json::from_str::<serde_json::Value>(&event.data);
2238                        let value = match value {
2239                            Ok(value) => value,
2240                            Err(error) if is_subscription => {
2241                                if let Some(id) = &reply_id {
2242                                    let _ = tx
2243                                        .send(transport_error_frame(
2244                                            id,
2245                                            &format!(
2246                                                "subscription stream returned invalid JSON: {error}"
2247                                            ),
2248                                        ))
2249                                        .await;
2250                                }
2251                                return;
2252                            }
2253                            Err(_) => {
2254                                let _ = tx.send(event.data).await;
2255                                continue;
2256                            }
2257                        };
2258                        let is_terminal = value.get("id").zip(reply_id.as_ref()).is_some_and(
2259                            |(actual, expected)| json_request_ids_match(actual, expected),
2260                        ) && (value.get("result").is_some()
2261                            || value.get("error").is_some());
2262
2263                        if is_subscription {
2264                            let violation = if is_terminal {
2265                                if value.get("error").is_some() {
2266                                    None
2267                                } else if !subscription_acknowledged {
2268                                    Some("subscriptions/listen completed before acknowledgment")
2269                                } else if !value
2270                                    .pointer(
2271                                        "/result/_meta/io.modelcontextprotocol~1subscriptionId",
2272                                    )
2273                                    .zip(reply_id.as_ref())
2274                                    .is_some_and(|(actual, expected)| {
2275                                        json_request_ids_match(actual, expected)
2276                                    })
2277                                {
2278                                    Some(
2279                                        "subscriptions/listen result carried a missing or mismatched subscription ID",
2280                                    )
2281                                } else {
2282                                    None
2283                                }
2284                            } else if value.get("method").is_some() && value.get("id").is_none() {
2285                                let correlated = value
2286                                    .pointer(
2287                                        "/params/_meta/io.modelcontextprotocol~1subscriptionId",
2288                                    )
2289                                    .zip(reply_id.as_ref())
2290                                    .is_some_and(|(actual, expected)| {
2291                                        json_request_ids_match(actual, expected)
2292                                    });
2293                                let is_acknowledgment =
2294                                    value.get("method").and_then(serde_json::Value::as_str)
2295                                        == Some(notifications::SUBSCRIPTIONS_ACKNOWLEDGED);
2296                                if !correlated {
2297                                    Some(
2298                                        "subscription notification carried a missing or mismatched subscription ID",
2299                                    )
2300                                } else if !subscription_acknowledged && !is_acknowledgment {
2301                                    Some("subscription notification arrived before acknowledgment")
2302                                } else if subscription_acknowledged && is_acknowledgment {
2303                                    Some("subscription stream sent a duplicate acknowledgment")
2304                                } else {
2305                                    if is_acknowledgment {
2306                                        subscription_acknowledged = true;
2307                                    }
2308                                    None
2309                                }
2310                            } else {
2311                                Some("subscription stream returned an unrelated JSON-RPC message")
2312                            };
2313                            if let Some(message) = violation {
2314                                if let Some(id) = &reply_id {
2315                                    let _ = tx.send(transport_error_frame(id, message)).await;
2316                                }
2317                                return;
2318                            }
2319                        }
2320                        let _ = tx.send(event.data).await;
2321                        if is_terminal {
2322                            // The request is complete. Close the response
2323                            // body ourselves even if a non-conforming server
2324                            // leaves the SSE stream open after its final reply.
2325                            return;
2326                        }
2327                    }
2328                }
2329            }
2330            Err(e) => {
2331                tracing::warn!(error = %request_error_message(&e), "POST SSE stream error");
2332                break;
2333            }
2334        }
2335    }
2336
2337    // If the POST SSE stream closed with a retry hint but no data,
2338    // the server expects us to reconnect the GET notification stream.
2339    // Signal the SSE loop to close its current stream and reconnect
2340    // with the updated last_event_id and sse_retry_delay.
2341    if !is_modern_request && had_retry && !had_data {
2342        sse_reconnect_signal.notify_one();
2343    } else {
2344        // The response stream closed without ever delivering a
2345        // terminal response. Acknowledgments and ordinary
2346        // notifications do not complete a request, so wake the
2347        // caller rather than leave it hanging. A response has
2348        // no client request to wake; log instead.
2349        let reason = if had_data {
2350            "server closed the response stream before the final reply"
2351        } else {
2352            "server closed the response stream without a reply"
2353        };
2354        if let Some(id) = &reply_id {
2355            let _ = tx.send(transport_error_frame(id, reason)).await;
2356        } else {
2357            tracing::warn!(reason, "reply POST stream closed unexpectedly");
2358        }
2359    }
2360}
2361
2362// =============================================================================
2363// Session Termination
2364// =============================================================================
2365
2366/// Owned inputs for the best-effort session-termination `DELETE`, shared by
2367/// `close()` (awaited inline) and `Drop` (spawned, since `drop()` cannot
2368/// await).
2369struct SessionDeleteParams {
2370    client: reqwest::Client,
2371    url: String,
2372    session_id: String,
2373    headers: HashMap<String, String>,
2374    #[cfg(feature = "oauth-client")]
2375    token_provider: Option<Arc<dyn TokenProvider>>,
2376}
2377
2378/// Send the session-termination `DELETE`, best effort: errors and non-2xx
2379/// responses are ignored since the server will otherwise reclaim the session
2380/// on its own idle timeout.
2381async fn send_session_delete(params: SessionDeleteParams) {
2382    let SessionDeleteParams {
2383        client,
2384        url,
2385        session_id,
2386        headers,
2387        #[cfg(feature = "oauth-client")]
2388        token_provider,
2389    } = params;
2390
2391    let mut request = client
2392        .delete(&url)
2393        .header("mcp-session-id", &session_id)
2394        .timeout(SESSION_DELETE_TIMEOUT);
2395
2396    for (key, value) in &headers {
2397        request = request.header(key.as_str(), value.as_str());
2398    }
2399
2400    // Dynamic token provider overrides the static Authorization header.
2401    #[cfg(feature = "oauth-client")]
2402    if let Some(ref provider) = token_provider
2403        && let Ok(token) = provider.get_token().await
2404        && let Ok(auth_headers) = bearer_headers(&token)
2405    {
2406        request = request.headers(auth_headers);
2407    }
2408
2409    let _ = request.send().await;
2410}
2411
2412// =============================================================================
2413// SSE Stream Background Loop
2414// =============================================================================
2415
2416/// Parameters for the SSE background loop.
2417struct SseLoopParams {
2418    url: String,
2419    client: reqwest::Client,
2420    session_id: String,
2421    protocol_version: Option<String>,
2422    tx: mpsc::Sender<String>,
2423    last_event_id: Arc<RwLock<Option<String>>>,
2424    sse_retry_delay: Arc<RwLock<Option<Duration>>>,
2425    reconnect_signal: Arc<Notify>,
2426    connected: Arc<AtomicBool>,
2427    config: HttpClientConfig,
2428    #[cfg(feature = "oauth-client")]
2429    token_provider: Option<Arc<dyn TokenProvider>>,
2430}
2431
2432/// Background loop that maintains the SSE stream connection.
2433///
2434/// Opens a GET request with `Accept: text/event-stream` and parses
2435/// incoming SSE events. Events are pushed into the mpsc channel for
2436/// `recv()` to return. Supports reconnection with `Last-Event-ID`.
2437async fn sse_stream_loop(params: SseLoopParams) {
2438    let SseLoopParams {
2439        url,
2440        client,
2441        session_id,
2442        protocol_version,
2443        tx,
2444        last_event_id,
2445        sse_retry_delay,
2446        reconnect_signal,
2447        connected,
2448        config,
2449        #[cfg(feature = "oauth-client")]
2450        token_provider,
2451    } = params;
2452    let mut reconnect_attempts = 0u32;
2453
2454    loop {
2455        if !connected.load(Ordering::Acquire) {
2456            break;
2457        }
2458
2459        let mut request = client
2460            .get(&url)
2461            .header("Accept", "text/event-stream")
2462            .header("mcp-session-id", &session_id);
2463
2464        if let Some(ref version) = protocol_version {
2465            request = request.header("mcp-protocol-version", version);
2466        }
2467
2468        for (key, value) in &config.headers {
2469            request = request.header(key.as_str(), value.as_str());
2470        }
2471
2472        // Dynamic token provider overrides static Authorization header
2473        #[cfg(feature = "oauth-client")]
2474        if let Some(ref provider) = token_provider {
2475            match provider.get_token().await {
2476                Ok(token) => match bearer_headers(&token) {
2477                    Ok(headers) => request = request.headers(headers),
2478                    Err(error) => {
2479                        tracing::warn!(%error, "Token provider failed for SSE connection");
2480                        break;
2481                    }
2482                },
2483                Err(_) => {
2484                    tracing::warn!("Token provider failed for SSE connection");
2485                    break;
2486                }
2487            }
2488        }
2489
2490        // Send Last-Event-ID for stream resumption
2491        if let Some(ref lei) = *last_event_id.read().await {
2492            request = request.header("Last-Event-ID", lei.clone());
2493        }
2494
2495        let response = match request.send().await {
2496            Ok(r) if r.status().is_success() => {
2497                reconnect_attempts = 0;
2498                r
2499            }
2500            Ok(r) => {
2501                tracing::warn!(status = %r.status(), "SSE connection rejected");
2502                break;
2503            }
2504            Err(e) => {
2505                tracing::warn!(error = %request_error_message(&e), "SSE connection failed");
2506                if !config.sse_reconnect || reconnect_attempts >= config.max_sse_reconnect_attempts
2507                {
2508                    break;
2509                }
2510                reconnect_attempts += 1;
2511                let delay = sse_retry_delay
2512                    .read()
2513                    .await
2514                    .unwrap_or(config.sse_reconnect_delay);
2515                tokio::time::sleep(delay).await;
2516                continue;
2517            }
2518        };
2519
2520        // Parse SSE stream, also listening for reconnect signals from POST handlers
2521        let mut stream = response.bytes_stream();
2522        let mut parser = SseParser::with_limit(config.max_sse_event_size);
2523
2524        use futures::StreamExt;
2525        loop {
2526            tokio::select! {
2527                chunk = stream.next() => {
2528                    match chunk {
2529                        Some(Ok(bytes)) => {
2530                            let text = String::from_utf8_lossy(&bytes);
2531                            let events = match parser.feed(&text) {
2532                                Ok(events) => events,
2533                                Err(e) => {
2534                                    // A single event exceeded the cap;
2535                                    // terminate the stream (no reconnect,
2536                                    // the server would just repeat it)
2537                                    // instead of buffering without bound.
2538                                    tracing::error!(error = %e, "SSE stream terminated");
2539                                    connected.store(false, Ordering::Release);
2540                                    return;
2541                                }
2542                            };
2543                            for event in events {
2544                                if let Some(ref id) = event.id {
2545                                    *last_event_id.write().await = Some(id.clone());
2546                                }
2547                                if let Some(retry_ms) = event.retry {
2548                                    *sse_retry_delay.write().await = Some(Duration::from_millis(retry_ms));
2549                                }
2550                                if !event.data.is_empty() && tx.send(event.data).await.is_err() {
2551                                    return; // Channel closed, transport dropped
2552                                }
2553                            }
2554                        }
2555                        Some(Err(e)) => {
2556                            tracing::warn!(error = %request_error_message(&e), "SSE stream error");
2557                            break;
2558                        }
2559                        None => {
2560                            tracing::debug!("SSE stream ended");
2561                            break;
2562                        }
2563                    }
2564                }
2565                _ = reconnect_signal.notified() => {
2566                    tracing::debug!("SSE reconnect signal received, closing current stream");
2567                    break;
2568                }
2569            }
2570        }
2571
2572        // Attempt reconnection
2573        if !config.sse_reconnect
2574            || !connected.load(Ordering::Acquire)
2575            || reconnect_attempts >= config.max_sse_reconnect_attempts
2576        {
2577            break;
2578        }
2579        reconnect_attempts += 1;
2580        let delay = sse_retry_delay
2581            .read()
2582            .await
2583            .unwrap_or(config.sse_reconnect_delay);
2584        tracing::info!(
2585            attempt = reconnect_attempts,
2586            max = config.max_sse_reconnect_attempts,
2587            delay_ms = delay.as_millis() as u64,
2588            "Reconnecting SSE stream"
2589        );
2590        tokio::time::sleep(delay).await;
2591    }
2592}
2593
2594// =============================================================================
2595// SSE Parser
2596// =============================================================================
2597
2598/// Extract JSON messages from a response body.
2599///
2600/// If the body is SSE-formatted (`event: message\ndata: ...\n\n`), extracts the
2601/// `data:` content from each event. Otherwise returns the body as-is.
2602/// Build a JSON-RPC error frame carrying `id`.
2603///
2604/// A post-session request POST is spawned in the background (so the message
2605/// loop can keep servicing the SSE stream), which means its failures happen
2606/// out of band from the caller. The caller is parked on the request id in the
2607/// message loop's correlation map; if the background POST fails before a real
2608/// response reaches the incoming channel, we must still deliver a frame with
2609/// this id or the caller hangs until the process exits. `-32000` is the
2610/// generic server-error code; the message names the transport-level cause.
2611fn transport_error_frame(id: &serde_json::Value, message: &str) -> String {
2612    serde_json::json!({
2613        "jsonrpc": "2.0",
2614        "id": id,
2615        "error": { "code": -32000, "message": message },
2616    })
2617    .to_string()
2618}
2619
2620/// [`transport_error_frame`] with structured `data` attached to the error.
2621fn transport_error_frame_with_data(
2622    id: &serde_json::Value,
2623    message: &str,
2624    data: serde_json::Value,
2625) -> String {
2626    serde_json::json!({
2627        "jsonrpc": "2.0",
2628        "id": id,
2629        "error": { "code": -32000, "message": message, "data": data },
2630    })
2631    .to_string()
2632}
2633
2634/// Whether a non-success status means the endpoint itself is gone, as opposed
2635/// to a transient failure of the one request it answered.
2636fn status_ends_transport(status: reqwest::StatusCode) -> bool {
2637    matches!(
2638        status,
2639        reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::GONE
2640    )
2641}
2642
2643/// The raw `Retry-After` header value, if present and readable.
2644fn retry_after_header(headers: &reqwest::header::HeaderMap) -> Option<String> {
2645    headers
2646        .get(reqwest::header::RETRY_AFTER)
2647        .and_then(|value| value.to_str().ok())
2648        .map(|value| value.trim().to_string())
2649}
2650
2651/// The `WWW-Authenticate` challenge, if the response carries one. Several
2652/// header lines are joined with `, `, which RFC 9110 defines as equivalent.
2653fn www_authenticate_header(headers: &reqwest::header::HeaderMap) -> Option<String> {
2654    let values: Vec<&str> = headers
2655        .get_all(reqwest::header::WWW_AUTHENTICATE)
2656        .iter()
2657        .filter_map(|value| value.to_str().ok())
2658        .map(str::trim)
2659        .collect();
2660    (!values.is_empty()).then(|| values.join(", "))
2661}
2662
2663/// The `data` attached to the error for a non-success HTTP status; read it
2664/// back with [`HttpStatusDetails::from_error`].
2665fn status_error_data(
2666    status: reqwest::StatusCode,
2667    retry_after: &Option<String>,
2668    www_authenticate: &Option<String>,
2669) -> serde_json::Value {
2670    let mut data = serde_json::json!({ "httpStatus": status.as_u16() });
2671    if let Some(raw) = retry_after {
2672        data["retryAfter"] = serde_json::Value::String(raw.clone());
2673    }
2674    if let Some(challenge) = www_authenticate {
2675        data["wwwAuthenticate"] = serde_json::Value::String(challenge.clone());
2676    }
2677    data
2678}
2679
2680/// Whether the status of a response with a JSON-RPC error body is recorded in
2681/// that error's `data`.
2682///
2683/// Limited to the statuses a caller acts on apart from the JSON-RPC code: an
2684/// authorization failure (401, 403) and a rate limit (429). Other statuses
2685/// that carry a JSON-RPC body (400 with -32602, 404 with -32601) are ordinary
2686/// in-band errors whose `data` stays exactly what the server sent.
2687fn status_annotates_jsonrpc_error(status: reqwest::StatusCode) -> bool {
2688    matches!(status.as_u16(), 401 | 403 | 429)
2689}
2690
2691/// Record `status_data` (see [`status_error_data`]) in the `data` of the
2692/// JSON-RPC error in `frame`, keeping whatever the server put there.
2693///
2694/// - No `data`: it becomes `status_data`.
2695/// - An object: the status keys are added where absent, so a server that sent
2696///   its own `httpStatus` wins.
2697/// - Any other value (a string, an array): it moves under `serverData` next
2698///   to the status keys.
2699fn merge_status_into_error(frame: &mut serde_json::Value, status_data: serde_json::Value) {
2700    let Some(error) = frame
2701        .get_mut("error")
2702        .and_then(serde_json::Value::as_object_mut)
2703    else {
2704        return;
2705    };
2706    let data = error.entry("data").or_insert(serde_json::Value::Null);
2707    match data {
2708        serde_json::Value::Null => *data = status_data,
2709        serde_json::Value::Object(existing) => {
2710            if let serde_json::Value::Object(additions) = status_data {
2711                for (key, value) in additions {
2712                    existing.entry(key).or_insert(value);
2713                }
2714            }
2715        }
2716        other => {
2717            let original = other.take();
2718            let mut wrapped = status_data;
2719            wrapped["serverData"] = original;
2720            *other = wrapped;
2721        }
2722    }
2723}
2724
2725/// The frame that tells the message loop the session behind request `id` is
2726/// gone: the `-32005` (SessionNotFound) error it recovers on.
2727fn session_expired_frame(id: &serde_json::Value) -> String {
2728    serde_json::json!({
2729        "jsonrpc": "2.0",
2730        "id": id,
2731        "error": { "code": -32005, "message": "Session not found (HTTP 404)" },
2732    })
2733    .to_string()
2734}
2735
2736/// The HTTP status of a non-success response, with its `Retry-After` and
2737/// `WWW-Authenticate` headers if the server sent them.
2738///
2739/// The failed request reaches the caller as an [`Error::JsonRpc`], and the
2740/// transport stays usable for the next request. The details ride in the
2741/// error's `data` as `{"httpStatus": 503, "retryAfter": "7"}`;
2742/// [`from_error`](Self::from_error) reads them back, for a retry policy that
2743/// needs to tell a transient failure from a dead server, or an authorization
2744/// failure from an ordinary error.
2745///
2746/// Two cases carry them:
2747///
2748/// - A status with no JSON-RPC error body (a 429 from a rate limiter, a 502
2749///   or 503 from a gateway). The error has code `-32000`.
2750/// - A 401, 403 or 429 whose body is a JSON-RPC error. That error is the
2751///   server's own, code and message included, with the status added to its
2752///   `data` (#1555): an existing object `data` keeps its keys and gains the
2753///   status keys where absent, and any other `data` moves under `serverData`.
2754///
2755/// A `404` or `410` with no JSON-RPC body still closes the transport, since
2756/// either means the endpoint is gone; a `404` on a session request instead
2757/// recovers the session. Errors from the initial `initialize` request, which
2758/// is sent synchronously, are reported as [`Error::Transport`] text and carry
2759/// no details.
2760#[derive(Debug, Clone, PartialEq, Eq)]
2761pub struct HttpStatusDetails {
2762    /// The HTTP status code of the response.
2763    pub status: u16,
2764    /// The raw `Retry-After` header value: delta-seconds (`"7"`) or an
2765    /// HTTP-date, passed through unparsed.
2766    pub retry_after: Option<String>,
2767    /// The raw `WWW-Authenticate` challenge, as sent with a 401 or a 403.
2768    /// Several header lines are joined with `, `.
2769    pub www_authenticate: Option<String>,
2770}
2771
2772impl HttpStatusDetails {
2773    /// Read the details from an error, if it came from a non-success HTTP
2774    /// status.
2775    pub fn from_error(error: &Error) -> Option<Self> {
2776        let Error::JsonRpc(error) = error else {
2777            return None;
2778        };
2779        let data = error.data.as_ref()?;
2780        let status = u16::try_from(data.get("httpStatus")?.as_u64()?).ok()?;
2781        let retry_after = data
2782            .get("retryAfter")
2783            .and_then(|value| value.as_str())
2784            .map(str::to_string);
2785        let www_authenticate = data
2786            .get("wwwAuthenticate")
2787            .and_then(|value| value.as_str())
2788            .map(str::to_string);
2789        Some(Self {
2790            status,
2791            retry_after,
2792            www_authenticate,
2793        })
2794    }
2795
2796    /// The `Retry-After` value as a delay, when it is delta-seconds. `None`
2797    /// when absent or an HTTP-date.
2798    pub fn retry_after_delay(&self) -> Option<Duration> {
2799        self.retry_after
2800            .as_deref()?
2801            .parse::<u64>()
2802            .ok()
2803            .map(Duration::from_secs)
2804    }
2805}
2806
2807/// Error for a request answered with `202 Accepted`, which acknowledges
2808/// notifications and replies but carries no response.
2809const ACCEPTED_WITHOUT_RESPONSE: &str =
2810    "server answered the request with 202 Accepted instead of a response";
2811
2812/// Error for a response body that holds no reply to the request it answers.
2813const NO_RESPONSE_IN_BODY: &str = "server response did not contain a reply to this request";
2814
2815/// Whether `frames` include the final response (a `result` or an `error`) to
2816/// the request with `id` (#1465).
2817fn frames_answer_request(frames: &[String], id: &serde_json::Value) -> bool {
2818    frames.iter().any(|frame| {
2819        serde_json::from_str::<serde_json::Value>(frame).is_ok_and(|value| {
2820            value
2821                .get("id")
2822                .is_some_and(|actual| json_request_ids_match(actual, id))
2823                && (value.get("result").is_some() || value.get("error").is_some())
2824        })
2825    })
2826}
2827
2828fn json_request_ids_match(left: &serde_json::Value, right: &serde_json::Value) -> bool {
2829    left == right
2830        || (RequestId::from_json(left)
2831            .is_some_and(|left| Some(left) == RequestId::from_json(right)))
2832        || match (left, right) {
2833            (serde_json::Value::Number(number), serde_json::Value::String(value))
2834            | (serde_json::Value::String(value), serde_json::Value::Number(number)) => number
2835                .as_i64()
2836                .is_some_and(|number| value.parse::<i64>() == Ok(number)),
2837            _ => false,
2838        }
2839}
2840
2841fn extract_json_messages(body: &str) -> Vec<String> {
2842    // A BOM ahead of the body would defeat both the SSE sniff below and the
2843    // JSON parse after it (#1303).
2844    let trimmed = crate::framing::clean_input_line(body);
2845    if trimmed.is_empty() {
2846        return Vec::new();
2847    }
2848
2849    // Heuristic: SSE bodies start with "event:" or "data:" or "id:" or ":"
2850    let looks_like_sse = trimmed.starts_with("event:")
2851        || trimmed.starts_with("data:")
2852        || trimmed.starts_with("id:")
2853        || trimmed.starts_with(':');
2854
2855    if looks_like_sse {
2856        // The body is already fully in memory here, so no event-size cap
2857        // applies: an unlimited parser never returns an error.
2858        let mut parser = SseParser::new();
2859        let events = parser.feed(body).unwrap_or_default();
2860        events.into_iter().map(|e| e.data).collect()
2861    } else {
2862        vec![trimmed.to_string()]
2863    }
2864}
2865
2866/// A parsed SSE event.
2867#[derive(Debug)]
2868struct SseEvent {
2869    /// Event ID (from `id:` line), if present. String per SSE spec.
2870    id: Option<String>,
2871    /// Event data (from `data:` lines, joined with newlines).
2872    data: String,
2873    /// Server-requested retry delay in milliseconds (from `retry:` line).
2874    retry: Option<u64>,
2875}
2876
2877/// Incremental SSE parser.
2878///
2879/// Handles partial chunks from the byte stream, buffering incomplete
2880/// lines across `feed()` calls. When constructed with
2881/// [`with_limit`](Self::with_limit), a single event whose buffered size
2882/// exceeds the limit terminates parsing with
2883/// [`Error::SseEventTooLarge`] instead of growing without bound
2884/// (rmcp #970 analog).
2885struct SseParser {
2886    /// Partial line buffer (when a chunk ends mid-line).
2887    buffer: String,
2888    /// Current event being parsed.
2889    current_id: Option<String>,
2890    current_data: Vec<String>,
2891    current_retry: Option<u64>,
2892    /// Total bytes across `current_data` lines, tracked incrementally.
2893    data_len: usize,
2894    /// Maximum buffered size for a single event.
2895    max_event_size: usize,
2896    /// Whether any chunk has been fed yet.
2897    ///
2898    /// A BOM is only a BOM at the very start of the stream; the same bytes
2899    /// later in it are a zero-width no-break space inside someone's data
2900    /// (#1303).
2901    at_stream_start: bool,
2902}
2903
2904impl SseParser {
2905    /// Create a parser with no event-size limit.
2906    fn new() -> Self {
2907        Self::with_limit(usize::MAX)
2908    }
2909
2910    /// Create a parser that rejects events buffering more than
2911    /// `max_event_size` bytes.
2912    fn with_limit(max_event_size: usize) -> Self {
2913        Self {
2914            buffer: String::new(),
2915            current_id: None,
2916            current_data: Vec::new(),
2917            current_retry: None,
2918            data_len: 0,
2919            max_event_size,
2920            at_stream_start: true,
2921        }
2922    }
2923
2924    /// Feed a chunk of text and return any complete events.
2925    ///
2926    /// Returns [`Error::SseEventTooLarge`] when the bytes buffered for a
2927    /// single in-progress event exceed the configured limit. The parser
2928    /// should not be fed further after an error.
2929    fn feed(&mut self, text: &str) -> Result<Vec<SseEvent>> {
2930        // A leading BOM would make the first field name unrecognisable, so an
2931        // event whose first line is `data:` would be dropped whole (#1303).
2932        let text = if std::mem::take(&mut self.at_stream_start) {
2933            text.strip_prefix('\u{feff}').unwrap_or(text)
2934        } else {
2935            text
2936        };
2937        self.buffer.push_str(text);
2938        let mut events = Vec::new();
2939
2940        // Process complete lines
2941        while let Some(newline_pos) = self.buffer.find('\n') {
2942            let line = self.buffer[..newline_pos]
2943                .trim_end_matches('\r')
2944                .to_string();
2945            self.buffer = self.buffer[newline_pos + 1..].to_string();
2946
2947            if line.is_empty() {
2948                // Empty line = end of event
2949                if !self.current_data.is_empty() || self.current_retry.is_some() {
2950                    events.push(SseEvent {
2951                        id: self.current_id.take(),
2952                        data: self.current_data.join("\n"),
2953                        retry: self.current_retry.take(),
2954                    });
2955                    self.current_data.clear();
2956                    self.data_len = 0;
2957                }
2958                self.current_id = None;
2959                self.current_retry = None;
2960            } else if let Some(value) = line.strip_prefix("id:") {
2961                let trimmed = value.trim();
2962                if !trimmed.is_empty() {
2963                    self.current_id = Some(trimmed.to_string());
2964                }
2965            } else if let Some(value) = line.strip_prefix("data:") {
2966                let data = value.trim().to_string();
2967                self.data_len += data.len();
2968                self.current_data.push(data);
2969            } else if let Some(value) = line.strip_prefix("retry:") {
2970                self.current_retry = value.trim().parse().ok();
2971            }
2972            // Lines starting with ':' are comments (keep-alive) -- ignored
2973            // Lines starting with 'event:' are event types -- ignored (we only care about data)
2974        }
2975
2976        // Everything still buffered belongs to a single unfinished event
2977        // (or an unfinished line of one). Cap it so a server that never
2978        // terminates an event can't grow the buffers without bound.
2979        let buffered = self.buffer.len() + self.data_len;
2980        if buffered > self.max_event_size {
2981            return Err(Error::SseEventTooLarge {
2982                size: buffered,
2983                limit: self.max_event_size,
2984            });
2985        }
2986
2987        Ok(events)
2988    }
2989}
2990
2991#[cfg(test)]
2992mod tests {
2993    use super::*;
2994    #[cfg(all(feature = "oauth-client", feature = "http"))]
2995    use crate::client::{
2996        OAuthAuthorizationAction, OAuthAuthorizationFlow, OAuthAuthorizationHandler,
2997        OAuthAuthorizationRequest, OAuthRedirectPolicy,
2998    };
2999
3000    // =========================================================================
3001    // 401 re-authentication (#1370)
3002    // =========================================================================
3003
3004    /// A provider that caches one token and only mints another after
3005    /// `invalidate`, which is what a real caching provider does and what makes
3006    /// re-sending a rejected token possible in the first place.
3007    #[cfg(all(feature = "oauth-client", feature = "http"))]
3008    #[derive(Clone, Default)]
3009    struct CachingProvider {
3010        minted: std::sync::Arc<std::sync::atomic::AtomicUsize>,
3011        invalidated: std::sync::Arc<std::sync::atomic::AtomicUsize>,
3012        cached: std::sync::Arc<tokio::sync::RwLock<Option<String>>>,
3013    }
3014
3015    #[cfg(all(feature = "oauth-client", feature = "http"))]
3016    #[async_trait::async_trait]
3017    impl TokenProvider for CachingProvider {
3018        async fn get_token(&self) -> std::result::Result<String, OAuthClientError> {
3019            use std::sync::atomic::Ordering;
3020            if let Some(token) = self.cached.read().await.clone() {
3021                return Ok(token);
3022            }
3023            let mut cached = self.cached.write().await;
3024            if let Some(token) = cached.clone() {
3025                return Ok(token);
3026            }
3027            let token = format!("token-{}", self.minted.fetch_add(1, Ordering::SeqCst));
3028            *cached = Some(token.clone());
3029            Ok(token)
3030        }
3031
3032        async fn invalidate(&self) {
3033            use std::sync::atomic::Ordering;
3034            self.invalidated.fetch_add(1, Ordering::SeqCst);
3035            *self.cached.write().await = None;
3036        }
3037    }
3038
3039    /// Stands in for a provider that has not overridden `invalidate`: it can
3040    /// never replace its token, so the retry must stop rather than loop.
3041    #[cfg(feature = "oauth-client")]
3042    #[derive(Clone)]
3043    struct StuckProvider;
3044
3045    #[cfg(feature = "oauth-client")]
3046    #[async_trait::async_trait]
3047    impl TokenProvider for StuckProvider {
3048        async fn get_token(&self) -> std::result::Result<String, OAuthClientError> {
3049            Ok("stale".to_string())
3050        }
3051    }
3052
3053    /// A server that rejects `token-0` and `stale` with a `401` Bearer
3054    /// challenge and accepts anything else, so recovery shows up as a status
3055    /// change. `challenge` controls whether the `WWW-Authenticate` header is
3056    /// present at all.
3057    #[cfg(all(feature = "oauth-client", feature = "http"))]
3058    async fn unauthorizing_server(
3059        challenge: bool,
3060        seen: std::sync::Arc<std::sync::atomic::AtomicUsize>,
3061    ) -> (String, tokio::task::JoinHandle<()>) {
3062        use axum::http::{HeaderMap, StatusCode};
3063        use axum::response::IntoResponse;
3064        use std::sync::atomic::Ordering;
3065
3066        let app = axum::Router::new().route(
3067            "/",
3068            axum::routing::post(move |headers: HeaderMap| {
3069                let seen = seen.clone();
3070                async move {
3071                    seen.fetch_add(1, Ordering::SeqCst);
3072                    let accepted = headers
3073                        .get("authorization")
3074                        .and_then(|v| v.to_str().ok())
3075                        .is_some_and(|v| v != "Bearer token-0" && v != "Bearer stale");
3076                    if accepted {
3077                        return (StatusCode::OK, "{}").into_response();
3078                    }
3079                    if challenge {
3080                        (
3081                            StatusCode::UNAUTHORIZED,
3082                            [(
3083                                "www-authenticate",
3084                                r#"Bearer error="invalid_token", error_description="expired""#,
3085                            )],
3086                            "",
3087                        )
3088                            .into_response()
3089                    } else {
3090                        (StatusCode::UNAUTHORIZED, "").into_response()
3091                    }
3092                }
3093            }),
3094        );
3095
3096        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3097        let url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
3098        let handle = tokio::spawn(async move {
3099            let _ = axum::serve(listener, app).await;
3100        });
3101        (url, handle)
3102    }
3103
3104    /// Drive `send_http_request` with an already-authorized request, the way
3105    /// the transport does.
3106    #[cfg(all(feature = "oauth-client", feature = "http"))]
3107    async fn send_with(
3108        url: &str,
3109        provider: std::sync::Arc<dyn TokenProvider>,
3110    ) -> reqwest::StatusCode {
3111        let token = provider.get_token().await.unwrap();
3112        let request = reqwest::Client::new()
3113            .post(url)
3114            .headers(bearer_headers(&token).unwrap());
3115        match send_http_request(request, url, "tools/call", Some(provider), None, 0).await {
3116            Ok(response) => response.status(),
3117            Err(_) => panic!("the request itself must not fail"),
3118        }
3119    }
3120
3121    /// #1370: the reported case. An expired token is discarded and replaced
3122    /// rather than re-sent.
3123    #[cfg(all(feature = "oauth-client", feature = "http"))]
3124    #[tokio::test]
3125    async fn a_401_challenge_replaces_the_rejected_token_and_retries() {
3126        use std::sync::atomic::Ordering;
3127        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3128        let (url, _server) = unauthorizing_server(true, seen.clone()).await;
3129        let provider = CachingProvider::default();
3130
3131        let status = send_with(&url, std::sync::Arc::new(provider.clone())).await;
3132
3133        assert_eq!(status, reqwest::StatusCode::OK, "the retry must succeed");
3134        assert_eq!(
3135            provider.invalidated.load(Ordering::SeqCst),
3136            1,
3137            "without discarding it, the retry re-sends the rejected token"
3138        );
3139        assert_eq!(provider.minted.load(Ordering::SeqCst), 2);
3140        assert_eq!(seen.load(Ordering::SeqCst), 2, "exactly one retry");
3141    }
3142
3143    /// The bound. A provider that cannot replace its token must not turn a
3144    /// `401` into a retry loop.
3145    #[cfg(all(feature = "oauth-client", feature = "http"))]
3146    #[tokio::test]
3147    async fn a_provider_that_cannot_re_authenticate_stops_after_one_retry() {
3148        use std::sync::atomic::Ordering;
3149        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3150        let (url, _server) = unauthorizing_server(true, seen.clone()).await;
3151
3152        let status = send_with(&url, std::sync::Arc::new(StuckProvider)).await;
3153
3154        assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
3155        assert_eq!(
3156            seen.load(Ordering::SeqCst),
3157            2,
3158            "one original and one retry, and no more"
3159        );
3160    }
3161
3162    /// A bare `401` with no challenge is somebody else's, commonly an
3163    /// intermediary. Retrying it would spend a token fetch on a server that
3164    /// never asked for one.
3165    #[cfg(all(feature = "oauth-client", feature = "http"))]
3166    #[tokio::test]
3167    async fn a_401_without_a_challenge_is_not_retried() {
3168        use std::sync::atomic::Ordering;
3169        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3170        let (url, _server) = unauthorizing_server(false, seen.clone()).await;
3171        let provider = CachingProvider::default();
3172
3173        let status = send_with(&url, std::sync::Arc::new(provider.clone())).await;
3174
3175        assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
3176        assert_eq!(seen.load(Ordering::SeqCst), 1, "no retry");
3177        assert_eq!(provider.invalidated.load(Ordering::SeqCst), 0);
3178    }
3179
3180    /// `invalidate` defaults to doing nothing, so a provider written before it
3181    /// existed still compiles and still behaves as it did.
3182    #[cfg(feature = "oauth-client")]
3183    #[tokio::test]
3184    async fn invalidate_defaults_to_a_no_op() {
3185        let provider = StuckProvider;
3186        provider.invalidate().await;
3187        assert_eq!(provider.get_token().await.unwrap(), "stale");
3188    }
3189
3190    // =========================================================================
3191    // #1467: a real auth-code provider reauthorizes through invalidate()
3192    // =========================================================================
3193
3194    /// Percent-decode an `application/x-www-form-urlencoded` body. `urlencoding`
3195    /// is already a required dependency of `oauth-client`, so this avoids
3196    /// pulling in `axum`'s `form` extractor (and `serde_urlencoded`) just for
3197    /// one test fixture.
3198    #[cfg(all(feature = "oauth-client", feature = "http"))]
3199    fn parse_form_body(body: &[u8]) -> HashMap<String, String> {
3200        String::from_utf8_lossy(body)
3201            .split('&')
3202            .filter(|pair| !pair.is_empty())
3203            .filter_map(|pair| {
3204                let mut parts = pair.splitn(2, '=');
3205                let key = urlencoding::decode(parts.next()?).ok()?.into_owned();
3206                let value = urlencoding::decode(parts.next().unwrap_or(""))
3207                    .ok()?
3208                    .into_owned();
3209                Some((key, value))
3210            })
3211            .collect()
3212    }
3213
3214    /// One axum app standing in for both the protected resource and its
3215    /// authorization server, so [`OAuthAuthorizationFlow`] can discover,
3216    /// authorize, and refresh against real HTTP rather than a mock
3217    /// [`OAuthHttpClient`]. `/mcp` rejects everything except
3218    /// `refreshed-access-token`, so a successful retry proves the whole
3219    /// invalidate-then-refresh path rather than just that a token exists.
3220    #[cfg(all(feature = "oauth-client", feature = "http"))]
3221    async fn spawn_resource_server_fixture()
3222    -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
3223        use axum::body::Bytes;
3224        use axum::http::{HeaderMap, StatusCode};
3225        use axum::response::IntoResponse;
3226        use std::sync::atomic::Ordering;
3227
3228        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3229        let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
3230        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3231
3232        let mcp_base = base.clone();
3233        let mcp_seen = seen.clone();
3234        let prm_base = base.clone();
3235        let as_base = base.clone();
3236
3237        let app = axum::Router::new()
3238            .route(
3239                "/mcp",
3240                axum::routing::post(move |headers: HeaderMap| {
3241                    let seen = mcp_seen.clone();
3242                    let base = mcp_base.clone();
3243                    async move {
3244                        seen.fetch_add(1, Ordering::SeqCst);
3245                        let accepted = headers
3246                            .get("authorization")
3247                            .and_then(|v| v.to_str().ok())
3248                            .is_some_and(|v| v == "Bearer refreshed-access-token");
3249                        if accepted {
3250                            return (StatusCode::OK, "{}").into_response();
3251                        }
3252                        (
3253                            StatusCode::UNAUTHORIZED,
3254                            [(
3255                                "www-authenticate",
3256                                format!(
3257                                    r#"Bearer error="invalid_token", resource_metadata="{base}/.well-known/oauth-protected-resource""#
3258                                ),
3259                            )],
3260                            "",
3261                        )
3262                            .into_response()
3263                    }
3264                }),
3265            )
3266            .route(
3267                "/.well-known/oauth-protected-resource",
3268                axum::routing::get(move || {
3269                    let base = prm_base.clone();
3270                    async move {
3271                        axum::Json(serde_json::json!({
3272                            "resource": format!("{base}/mcp"),
3273                            "authorization_servers": [format!("{base}/as")],
3274                            "scopes_supported": ["mcp.scope"]
3275                        }))
3276                    }
3277                }),
3278            )
3279            .route(
3280                "/.well-known/oauth-authorization-server/as",
3281                axum::routing::get(move || {
3282                    let base = as_base.clone();
3283                    async move {
3284                        axum::Json(serde_json::json!({
3285                            "issuer": format!("{base}/as"),
3286                            "authorization_endpoint": format!("{base}/as/authorize"),
3287                            "token_endpoint": format!("{base}/as/token"),
3288                            "code_challenge_methods_supported": ["S256"],
3289                            "token_endpoint_auth_methods_supported": ["none"]
3290                        }))
3291                    }
3292                }),
3293            )
3294            .route(
3295                "/as/token",
3296                axum::routing::post(move |body: Bytes| async move {
3297                    let fields = parse_form_body(&body);
3298                    match fields.get("grant_type").map(String::as_str) {
3299                        Some("authorization_code") => axum::Json(serde_json::json!({
3300                            "access_token": "initial-access-token",
3301                            "token_type": "Bearer",
3302                            "expires_in": 3600,
3303                            "refresh_token": "refresh-token-1",
3304                            "scope": "mcp.scope"
3305                        }))
3306                        .into_response(),
3307                        Some("refresh_token") => axum::Json(serde_json::json!({
3308                            "access_token": "refreshed-access-token",
3309                            "token_type": "Bearer",
3310                            "expires_in": 3600,
3311                            "scope": "mcp.scope"
3312                        }))
3313                        .into_response(),
3314                        other => (
3315                            StatusCode::BAD_REQUEST,
3316                            format!("unexpected grant_type: {other:?}"),
3317                        )
3318                            .into_response(),
3319                    }
3320                }),
3321            );
3322
3323        tokio::spawn(async move {
3324            let _ = axum::serve(listener, app).await;
3325        });
3326        (base, seen)
3327    }
3328
3329    /// Returns [`OAuthAuthorizationAction::CallbackUrl`] immediately with a
3330    /// synthetic authorization code, so the flow completes without an actual
3331    /// browser redirect or loopback listener.
3332    #[cfg(all(feature = "oauth-client", feature = "http"))]
3333    #[derive(Clone, Default)]
3334    struct ImmediateCallbackHandler;
3335
3336    #[cfg(all(feature = "oauth-client", feature = "http"))]
3337    #[async_trait::async_trait]
3338    impl OAuthAuthorizationHandler for ImmediateCallbackHandler {
3339        async fn authorize(
3340            &self,
3341            request: OAuthAuthorizationRequest,
3342        ) -> std::result::Result<OAuthAuthorizationAction, OAuthClientError> {
3343            let authorization_url = reqwest::Url::parse(&request.authorization_url).unwrap();
3344            let state = authorization_url
3345                .query_pairs()
3346                .find(|(name, _)| name == "state")
3347                .unwrap()
3348                .1
3349                .into_owned();
3350            let mut callback = reqwest::Url::parse(&request.redirect_uri).unwrap();
3351            callback
3352                .query_pairs_mut()
3353                .append_pair("code", "authorization-code")
3354                .append_pair("state", &state)
3355                .append_pair("iss", &request.issuer);
3356            Ok(OAuthAuthorizationAction::CallbackUrl(callback.to_string()))
3357        }
3358    }
3359
3360    /// #1467 end-to-end: a real [`OAuthAuthorizationFlow`] behind the HTTP
3361    /// transport. The resource server 401s the token minted by the initial
3362    /// code exchange; the fix under test is that `invalidate()` now marks it
3363    /// unusable so `get_token()` performs an RFC 6749 refresh instead of
3364    /// handing back the same rejected token, and the retry succeeds.
3365    ///
3366    /// This exercises the real provider rather than a mock `TokenProvider`
3367    /// (unlike `a_401_challenge_replaces_the_rejected_token_and_retries`, which
3368    /// only proves the transport calls `invalidate()` before retrying, not that
3369    /// a real provider does anything useful when it does).
3370    ///
3371    /// `OAuthAuthorizationCode` is not used here instead: its only public entry
3372    /// point drives a real loopback callback listener (`start()` /
3373    /// `wait_for_callback()`), which has no existing test scaffolding to reuse
3374    /// across this module boundary and would not exercise anything beyond what
3375    /// this test and the provider-level refresh tests in `oauth_authcode.rs`
3376    /// already cover. `OAuthAuthorizationFlow` reaches the same real
3377    /// discovery/token-exchange/refresh code through its `CallbackUrl`
3378    /// authorization action, which completes without a real redirect.
3379    #[cfg(all(feature = "oauth-client", feature = "http"))]
3380    #[tokio::test]
3381    async fn a_real_auth_code_flow_provider_reauthorizes_through_invalidate() {
3382        use std::sync::atomic::Ordering;
3383
3384        let (base, seen) = spawn_resource_server_fixture().await;
3385        let resource = format!("{base}/mcp");
3386
3387        let flow = OAuthAuthorizationFlow::builder(&resource)
3388            .redirect_policy(OAuthRedirectPolicy::fixed(format!("{base}/callback")))
3389            .pre_registered_client("test-client", None)
3390            .authorization_handler(ImmediateCallbackHandler)
3391            .build()
3392            .unwrap();
3393
3394        flow.authorize(["mcp.scope"]).await.unwrap();
3395        assert_eq!(
3396            flow.get_token().await.unwrap(),
3397            "initial-access-token",
3398            "sanity: the code exchange installed the token the fixture rejects"
3399        );
3400
3401        let status = send_with(&resource, std::sync::Arc::new(flow.clone())).await;
3402
3403        assert_eq!(status, reqwest::StatusCode::OK, "the retry must succeed");
3404        assert_eq!(
3405            flow.get_token().await.unwrap(),
3406            "refreshed-access-token",
3407            "invalidate() must have caused get_token() to refresh"
3408        );
3409        assert_eq!(
3410            seen.load(Ordering::SeqCst),
3411            3,
3412            "discovery probe, rejected first attempt, successful retry"
3413        );
3414    }
3415
3416    // =========================================================================
3417    // Drop / session teardown (#1479)
3418    // =========================================================================
3419
3420    /// Dropping a transport outside any Tokio runtime must not panic, even
3421    /// when it has a session id set, which is the one path that would
3422    /// otherwise try to spawn the teardown `DELETE`.
3423    #[test]
3424    fn drop_outside_a_runtime_does_not_panic() {
3425        let mut transport = HttpClientTransport::new("http://127.0.0.1:1");
3426        transport.session_id = Some("session-outside-runtime".to_string());
3427        transport.protocol_version = Some(crate::protocol::LATEST_PROTOCOL_VERSION.to_string());
3428        drop(transport);
3429    }
3430
3431    // =========================================================================
3432    // SseParser tests
3433    // =========================================================================
3434
3435    #[test]
3436    fn test_parse_complete_event() {
3437        let mut parser = SseParser::new();
3438        let events = parser
3439            .feed("id: 1\nevent: message\ndata: {\"hello\":\"world\"}\n\n")
3440            .unwrap();
3441
3442        assert_eq!(events.len(), 1);
3443        assert_eq!(events[0].id, Some("1".to_string()));
3444        assert_eq!(events[0].data, "{\"hello\":\"world\"}");
3445    }
3446
3447    /// #1303: a BOM ahead of the stream makes the first field name
3448    /// unrecognisable, so an event opening with `data:` is dropped whole.
3449    #[test]
3450    fn test_parse_strips_a_bom_at_the_start_of_the_stream() {
3451        let mut parser = SseParser::new();
3452        let events = parser
3453            .feed("\u{feff}data: {\"hello\":\"world\"}\n\n")
3454            .unwrap();
3455
3456        assert_eq!(events.len(), 1, "the opening event must survive a BOM");
3457        assert_eq!(events[0].data, "{\"hello\":\"world\"}");
3458    }
3459
3460    /// Only at the start. The same bytes later in the stream are a zero-width
3461    /// no-break space inside someone's data, and altering it would hand the
3462    /// caller content the server never sent.
3463    #[test]
3464    fn test_parse_keeps_a_bom_inside_the_stream() {
3465        let mut parser = SseParser::new();
3466        assert!(parser.feed("data: first\n\n").unwrap().len() == 1);
3467
3468        let events = parser.feed("data: \u{feff}second\n\n").unwrap();
3469        assert_eq!(events.len(), 1);
3470        assert_eq!(events[0].data, "\u{feff}second");
3471    }
3472
3473    /// #1303: the same gap on the non-streaming path, where a BOM would
3474    /// defeat both the SSE sniff and the JSON parse behind it.
3475    #[test]
3476    fn test_extract_json_messages_strips_a_leading_bom() {
3477        let json = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
3478        assert_eq!(
3479            extract_json_messages(&format!("\u{feff}{json}")),
3480            vec![json.to_string()],
3481        );
3482
3483        let sse = extract_json_messages(&format!("\u{feff}data: {json}\n\n"));
3484        assert_eq!(
3485            sse,
3486            vec![json.to_string()],
3487            "a BOM must not hide an SSE body"
3488        );
3489    }
3490
3491    #[test]
3492    fn test_parse_multiple_events() {
3493        let mut parser = SseParser::new();
3494        let events = parser
3495            .feed("id: 1\ndata: first\n\nid: 2\ndata: second\n\nid: 3\ndata: third\n\n")
3496            .unwrap();
3497
3498        assert_eq!(events.len(), 3);
3499        assert_eq!(events[0].data, "first");
3500        assert_eq!(events[1].data, "second");
3501        assert_eq!(events[2].data, "third");
3502        assert_eq!(events[0].id, Some("1".to_string()));
3503        assert_eq!(events[1].id, Some("2".to_string()));
3504        assert_eq!(events[2].id, Some("3".to_string()));
3505    }
3506
3507    #[test]
3508    fn test_parse_partial_chunks() {
3509        let mut parser = SseParser::new();
3510
3511        // First chunk: partial event
3512        let events = parser.feed("id: 1\nda").unwrap();
3513        assert!(events.is_empty());
3514
3515        // Second chunk: completes the event
3516        let events = parser.feed("ta: hello\n\n").unwrap();
3517        assert_eq!(events.len(), 1);
3518        assert_eq!(events[0].id, Some("1".to_string()));
3519        assert_eq!(events[0].data, "hello");
3520    }
3521
3522    #[test]
3523    fn test_parse_multiline_data() {
3524        let mut parser = SseParser::new();
3525        let events = parser
3526            .feed("id: 1\ndata: line1\ndata: line2\ndata: line3\n\n")
3527            .unwrap();
3528
3529        assert_eq!(events.len(), 1);
3530        assert_eq!(events[0].data, "line1\nline2\nline3");
3531    }
3532
3533    #[test]
3534    fn test_parse_comment_lines() {
3535        let mut parser = SseParser::new();
3536        let events = parser.feed(": keep-alive\nid: 1\ndata: hello\n\n").unwrap();
3537
3538        assert_eq!(events.len(), 1);
3539        assert_eq!(events[0].data, "hello");
3540    }
3541
3542    #[test]
3543    fn test_parse_event_without_id() {
3544        let mut parser = SseParser::new();
3545        let events = parser.feed("data: no-id-event\n\n").unwrap();
3546
3547        assert_eq!(events.len(), 1);
3548        assert_eq!(events[0].id, None);
3549        assert_eq!(events[0].data, "no-id-event");
3550    }
3551
3552    #[test]
3553    fn test_empty_data_no_event() {
3554        let mut parser = SseParser::new();
3555        let events = parser.feed("id: 1\n\n").unwrap();
3556
3557        // No data lines = no event produced
3558        assert!(events.is_empty());
3559    }
3560
3561    #[test]
3562    fn test_parse_crlf_line_endings() {
3563        let mut parser = SseParser::new();
3564        let events = parser.feed("id: 1\r\ndata: crlf\r\n\r\n").unwrap();
3565
3566        assert_eq!(events.len(), 1);
3567        assert_eq!(events[0].data, "crlf");
3568    }
3569
3570    #[test]
3571    fn test_parse_json_data() {
3572        let mut parser = SseParser::new();
3573        let json = r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"token":"t1","progress":50}}"#;
3574        let input = format!("id: 42\nevent: message\ndata: {}\n\n", json);
3575        let events = parser.feed(&input).unwrap();
3576
3577        assert_eq!(events.len(), 1);
3578        assert_eq!(events[0].id, Some("42".to_string()));
3579
3580        // Verify it's valid JSON
3581        let parsed: serde_json::Value = serde_json::from_str(&events[0].data).unwrap();
3582        assert_eq!(parsed["method"], "notifications/progress");
3583    }
3584
3585    #[test]
3586    fn test_event_exceeding_limit_is_rejected() {
3587        let mut parser = SseParser::with_limit(64);
3588
3589        // An unterminated data line larger than the limit trips the cap.
3590        let big = "data: ".to_string() + &"x".repeat(128);
3591        let err = parser.feed(&big).unwrap_err();
3592        match err {
3593            Error::SseEventTooLarge { size, limit } => {
3594                assert!(size > 64, "size {} should exceed limit", size);
3595                assert_eq!(limit, 64);
3596            }
3597            other => panic!("expected SseEventTooLarge, got {:?}", other),
3598        }
3599    }
3600
3601    #[test]
3602    fn test_accumulated_data_lines_count_toward_limit() {
3603        let mut parser = SseParser::with_limit(64);
3604
3605        // Many complete data lines belonging to one unterminated event.
3606        let mut result = Ok(Vec::new());
3607        for _ in 0..10 {
3608            result = parser.feed("data: 0123456789\n");
3609            if result.is_err() {
3610                break;
3611            }
3612        }
3613        assert!(matches!(result, Err(Error::SseEventTooLarge { .. })));
3614    }
3615
3616    #[test]
3617    fn test_events_within_limit_pass() {
3618        let mut parser = SseParser::with_limit(64);
3619        let events = parser.feed("data: hello\n\ndata: world\n\n").unwrap();
3620        assert_eq!(events.len(), 2);
3621    }
3622
3623    // =========================================================================
3624    // Config tests
3625    // =========================================================================
3626
3627    #[test]
3628    fn test_default_config() {
3629        let config = HttpClientConfig::default();
3630        assert!(config.auto_sse);
3631        assert_eq!(config.channel_capacity, 256);
3632        assert_eq!(config.request_timeout, Duration::from_secs(30));
3633        assert!(config.sse_reconnect);
3634        assert_eq!(config.sse_reconnect_delay, Duration::from_secs(1));
3635        assert_eq!(config.max_sse_reconnect_attempts, 5);
3636        assert!(config.headers.is_empty());
3637    }
3638
3639    // =========================================================================
3640    // Transport constructor tests
3641    // =========================================================================
3642
3643    #[test]
3644    fn test_new_transport() {
3645        let transport = HttpClientTransport::new("http://localhost:3000");
3646        assert_eq!(transport.url, "http://localhost:3000");
3647        assert!(transport.session_id.is_none());
3648        assert!(transport.protocol_version.is_none());
3649        assert!(transport.is_connected());
3650    }
3651
3652    #[test]
3653    fn test_with_config() {
3654        let config = HttpClientConfig {
3655            request_timeout: Duration::from_secs(60),
3656            sse_reconnect: false,
3657            ..Default::default()
3658        };
3659        let transport = HttpClientTransport::with_config("http://example.com", config);
3660        assert_eq!(transport.url, "http://example.com");
3661        assert_eq!(transport.config.request_timeout, Duration::from_secs(60));
3662        assert!(!transport.config.sse_reconnect);
3663    }
3664
3665    #[test]
3666    fn test_with_client() {
3667        let client = reqwest::Client::new();
3668        let transport = HttpClientTransport::with_client("http://example.com", client);
3669        assert_eq!(transport.url, "http://example.com");
3670        assert!(transport.is_connected());
3671    }
3672
3673    // =========================================================================
3674    // Auth builder tests
3675    // =========================================================================
3676
3677    #[test]
3678    fn test_bearer_token() {
3679        let transport =
3680            HttpClientTransport::new("http://localhost:3000").bearer_token("sk-test-token");
3681        assert_eq!(
3682            transport.config.headers.get("Authorization").unwrap(),
3683            "Bearer sk-test-token"
3684        );
3685    }
3686
3687    #[test]
3688    fn test_api_key() {
3689        let transport = HttpClientTransport::new("http://localhost:3000").api_key("sk-api-key-123");
3690        assert_eq!(
3691            transport.config.headers.get("Authorization").unwrap(),
3692            "Bearer sk-api-key-123"
3693        );
3694    }
3695
3696    #[test]
3697    fn test_api_key_header() {
3698        let transport =
3699            HttpClientTransport::new("http://localhost:3000").api_key_header("X-API-Key", "my-key");
3700        assert_eq!(transport.config.headers.get("X-API-Key").unwrap(), "my-key");
3701        assert!(!transport.config.headers.contains_key("Authorization"));
3702    }
3703
3704    #[test]
3705    fn test_basic_auth() {
3706        let transport =
3707            HttpClientTransport::new("http://localhost:3000").basic_auth("admin", "secret");
3708        let header = transport.config.headers.get("Authorization").unwrap();
3709        assert!(header.starts_with("Basic "));
3710        use base64::Engine;
3711        let decoded = base64::engine::general_purpose::STANDARD
3712            .decode(header.strip_prefix("Basic ").unwrap())
3713            .unwrap();
3714        assert_eq!(String::from_utf8(decoded).unwrap(), "admin:secret");
3715    }
3716
3717    #[test]
3718    fn test_custom_header() {
3719        let transport = HttpClientTransport::new("http://localhost:3000")
3720            .header("X-Custom", "value1")
3721            .header("X-Another", "value2");
3722        assert_eq!(transport.config.headers.get("X-Custom").unwrap(), "value1");
3723        assert_eq!(transport.config.headers.get("X-Another").unwrap(), "value2");
3724    }
3725
3726    #[test]
3727    fn test_chaining_with_config() {
3728        let config = HttpClientConfig {
3729            request_timeout: Duration::from_secs(60),
3730            ..Default::default()
3731        };
3732        let transport =
3733            HttpClientTransport::with_config("http://localhost:3000", config).bearer_token("tk");
3734        assert_eq!(transport.config.request_timeout, Duration::from_secs(60));
3735        assert_eq!(
3736            transport.config.headers.get("Authorization").unwrap(),
3737            "Bearer tk"
3738        );
3739    }
3740
3741    #[test]
3742    fn test_last_auth_wins() {
3743        let transport = HttpClientTransport::new("http://localhost:3000")
3744            .bearer_token("token1")
3745            .basic_auth("user", "pass");
3746        let header = transport.config.headers.get("Authorization").unwrap();
3747        assert!(header.starts_with("Basic "));
3748    }
3749
3750    #[test]
3751    fn test_config_bearer_token() {
3752        let config = HttpClientConfig::default().bearer_token("tk-123");
3753        assert_eq!(
3754            config.headers.get("Authorization").unwrap(),
3755            "Bearer tk-123"
3756        );
3757    }
3758
3759    #[test]
3760    fn test_config_header() {
3761        let config = HttpClientConfig::default().header("X-Foo", "bar");
3762        assert_eq!(config.headers.get("X-Foo").unwrap(), "bar");
3763    }
3764
3765    #[test]
3766    fn test_config_api_key_header() {
3767        let config = HttpClientConfig::default().api_key_header("X-Key", "secret");
3768        assert_eq!(config.headers.get("X-Key").unwrap(), "secret");
3769    }
3770
3771    #[test]
3772    fn test_config_basic_auth() {
3773        let config = HttpClientConfig::default().basic_auth("user", "pw");
3774        let header = config.headers.get("Authorization").unwrap();
3775        assert!(header.starts_with("Basic "));
3776    }
3777
3778    #[test]
3779    fn sep_2243_encodes_only_unsafe_values() {
3780        assert_eq!(encode_header_value("us west 1"), "us west 1");
3781        assert_eq!(encode_header_value(""), "");
3782        assert_eq!(encode_header_value(" padded "), "=?base64?IHBhZGRlZCA=?=");
3783        assert_eq!(
3784            encode_header_value("Hello, 世界"),
3785            "=?base64?SGVsbG8sIOS4lueVjA==?="
3786        );
3787    }
3788
3789    #[test]
3790    fn oauth_error_body_is_not_misclassified_as_jsonrpc() {
3791        assert!(!is_jsonrpc_error_response(&serde_json::json!({
3792            "error": "insufficient_scope",
3793            "error_description": "Token has insufficient scope"
3794        })));
3795        assert!(is_jsonrpc_error_response(&serde_json::json!({
3796            "jsonrpc": "2.0",
3797            "id": 1,
3798            "error": {
3799                "code": -32022,
3800                "message": "Unsupported protocol version"
3801            }
3802        })));
3803    }
3804
3805    #[test]
3806    fn sep_2243_validates_custom_header_annotations() {
3807        let mappings = custom_header_mappings(&serde_json::json!({
3808            "type": "object",
3809            "properties": {
3810                "region": {"type": "string", "x-mcp-header": "Region"},
3811                "priority": {"type": "integer", "x-mcp-header": "Priority"},
3812                "enabled": {"type": ["boolean", "null"], "x-mcp-header": "Enabled"}
3813            }
3814        }))
3815        .unwrap();
3816        assert_eq!(mappings.len(), 3);
3817
3818        for invalid in [
3819            // `number` is not an allowed header type (SEP-2243), alone or
3820            // combined with null.
3821            serde_json::json!({
3822                "type": "object",
3823                "properties": {"ratio": {"type": "number", "x-mcp-header": "Ratio"}}
3824            }),
3825            serde_json::json!({
3826                "type": "object",
3827                "properties": {"ratio": {"type": ["number", "null"], "x-mcp-header": "Ratio"}}
3828            }),
3829            serde_json::json!({
3830                "type": "object",
3831                "properties": {"value": {"type": "object", "x-mcp-header": "Value"}}
3832            }),
3833            serde_json::json!({
3834                "type": "object",
3835                "properties": {
3836                    "a": {"type": "string", "x-mcp-header": "Region"},
3837                    "b": {"type": "string", "x-mcp-header": "region"}
3838                }
3839            }),
3840            serde_json::json!({
3841                "type": "object",
3842                "properties": {"value": {"type": "string", "x-mcp-header": "Bad Header"}}
3843            }),
3844        ] {
3845            assert!(custom_header_mappings(&invalid).is_err());
3846        }
3847    }
3848
3849    #[test]
3850    fn sep_2243_filters_invalid_tools_and_caches_valid_mappings() {
3851        let mut transport = HttpClientTransport::new("http://localhost:3000");
3852        transport.protocol_version = Some(crate::protocol::PROTOCOL_VERSION_2026_07_28.to_string());
3853        let normalized = transport.normalize_incoming_message(
3854            serde_json::json!({
3855                "jsonrpc": "2.0",
3856                "id": 1,
3857                "result": {
3858                    "tools": [
3859                        {
3860                            "name": "valid",
3861                            "inputSchema": {
3862                                "type": "object",
3863                                "properties": {
3864                                    "region": {"type": "string", "x-mcp-header": "Region"}
3865                                }
3866                            }
3867                        },
3868                        {
3869                            "name": "invalid",
3870                            "inputSchema": {
3871                                "type": "object",
3872                                "properties": {
3873                                    "value": {"type": "array", "x-mcp-header": "Value"}
3874                                }
3875                            }
3876                        },
3877                        {
3878                            "name": "number_typed",
3879                            "inputSchema": {
3880                                "type": "object",
3881                                "properties": {
3882                                    "ratio": {"type": "number", "x-mcp-header": "Ratio"}
3883                                }
3884                            }
3885                        }
3886                    ]
3887                }
3888            })
3889            .to_string(),
3890        );
3891        let parsed: serde_json::Value = serde_json::from_str(&normalized).unwrap();
3892        assert_eq!(parsed["result"]["tools"].as_array().unwrap().len(), 1);
3893        assert!(transport.tool_header_mappings.contains_key("valid"));
3894        assert!(!transport.tool_header_mappings.contains_key("invalid"));
3895        assert!(!transport.tool_header_mappings.contains_key("number_typed"));
3896    }
3897
3898    fn tools_page(id: i64, tool: &str) -> String {
3899        serde_json::json!({
3900            "jsonrpc": "2.0",
3901            "id": id,
3902            "result": {
3903                "tools": [{
3904                    "name": tool,
3905                    "inputSchema": {
3906                        "type": "object",
3907                        "properties": {
3908                            "region": {"type": "string", "x-mcp-header": "Region"}
3909                        }
3910                    }
3911                }]
3912            }
3913        })
3914        .to_string()
3915    }
3916
3917    /// Only a first-page `tools/list` response replaces the mappings; a page
3918    /// requested with a cursor adds to them, and `tools/list_changed` drops
3919    /// them (#1589).
3920    #[test]
3921    fn tools_list_pages_merge_header_mappings_until_list_changed() {
3922        let mut transport = HttpClientTransport::new("http://localhost:3000");
3923        transport.protocol_version = Some(crate::protocol::PROTOCOL_VERSION_2026_07_28.to_string());
3924
3925        transport
3926            .pending_tools_lists
3927            .push((serde_json::json!(1), false));
3928        transport.normalize_incoming_message(tools_page(1, "first"));
3929        transport
3930            .pending_tools_lists
3931            .push((serde_json::json!(2), true));
3932        transport.normalize_incoming_message(tools_page(2, "second"));
3933        assert!(transport.pending_tools_lists.is_empty());
3934        assert!(transport.tool_header_mappings.contains_key("first"));
3935        assert!(transport.tool_header_mappings.contains_key("second"));
3936
3937        // A new first page replaces what was learned before.
3938        transport
3939            .pending_tools_lists
3940            .push((serde_json::json!(3), false));
3941        transport.normalize_incoming_message(tools_page(3, "third"));
3942        assert_eq!(transport.tool_header_mappings.len(), 1);
3943        assert!(transport.tool_header_mappings.contains_key("third"));
3944
3945        transport
3946            .pending_tools_lists
3947            .push((serde_json::json!(4), true));
3948        transport.normalize_incoming_message(tools_page(4, "fourth"));
3949        assert_eq!(transport.tool_header_mappings.len(), 2);
3950
3951        let notification = serde_json::json!({
3952            "jsonrpc": "2.0",
3953            "method": "notifications/tools/list_changed"
3954        })
3955        .to_string();
3956        assert_eq!(
3957            transport.normalize_incoming_message(notification.clone()),
3958            notification
3959        );
3960        assert!(transport.tool_header_mappings.is_empty());
3961    }
3962
3963    /// An error response for a pending `tools/list` still consumes its entry.
3964    #[test]
3965    fn tools_list_error_response_clears_pending_entry() {
3966        let mut transport = HttpClientTransport::new("http://localhost:3000");
3967        transport.protocol_version = Some(crate::protocol::PROTOCOL_VERSION_2026_07_28.to_string());
3968        transport
3969            .pending_tools_lists
3970            .push((serde_json::json!(7), true));
3971        transport.normalize_incoming_message(
3972            serde_json::json!({
3973                "jsonrpc": "2.0",
3974                "id": 7,
3975                "error": {"code": -32603, "message": "boom"}
3976            })
3977            .to_string(),
3978        );
3979        assert!(transport.pending_tools_lists.is_empty());
3980    }
3981
3982    // =========================================================================
3983    // Response vs. request classification (#1466)
3984    // =========================================================================
3985
3986    /// A response (id present, no method) -- the client's reply to a
3987    /// server-initiated request -- must never be tracked in `request_tasks`,
3988    /// even when its id collides with one of the client's own request ids.
3989    /// A request with the same id is tracked as before, and `cancel_request`
3990    /// on that id only ever touches the client's own request.
3991    #[tokio::test]
3992    async fn responses_are_not_tracked_in_request_tasks() {
3993        let mut transport = HttpClientTransport::new("http://127.0.0.1:1");
3994        transport.session_id = Some("test-session".to_string());
3995
3996        // The client's reply to a server request sharing id 7 with one of
3997        // the client's own (hypothetical) requests below.
3998        transport
3999            .send(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#)
4000            .await
4001            .unwrap();
4002        assert!(
4003            transport.request_tasks.is_empty(),
4004            "a response must never be tracked in request_tasks"
4005        );
4006
4007        // The client's own request using the same id is tracked normally.
4008        transport
4009            .send(r#"{"jsonrpc":"2.0","id":7,"method":"ping","params":{}}"#)
4010            .await
4011            .unwrap();
4012        assert!(
4013            transport.request_tasks.contains_key(&RequestId::Number(7)),
4014            "a request must still be tracked in request_tasks"
4015        );
4016
4017        // cancel_request on the colliding id only ever aborts the client's
4018        // own request POST, never a reply (which was never inserted).
4019        transport
4020            .cancel_request(&RequestId::Number(7))
4021            .await
4022            .unwrap();
4023        assert!(transport.request_tasks.is_empty());
4024    }
4025
4026    // =========================================================================
4027    // Redirect policy (#1475)
4028    // =========================================================================
4029
4030    fn url(s: &str) -> reqwest::Url {
4031        reqwest::Url::parse(s).unwrap()
4032    }
4033
4034    #[test]
4035    fn only_body_preserving_redirect_statuses_are_followed() {
4036        use reqwest::StatusCode;
4037        assert!(redirect_preserves_request(StatusCode::TEMPORARY_REDIRECT));
4038        assert!(redirect_preserves_request(StatusCode::PERMANENT_REDIRECT));
4039        for status in [
4040            StatusCode::MOVED_PERMANENTLY,
4041            StatusCode::FOUND,
4042            StatusCode::SEE_OTHER,
4043        ] {
4044            assert!(!redirect_preserves_request(status), "{status}");
4045        }
4046    }
4047
4048    #[test]
4049    fn same_origin_redirect_is_followed_by_default() {
4050        assert!(should_follow_redirect(
4051            &url("https://example.com/mcp"),
4052            &url("https://example.com/other"),
4053            false,
4054        ));
4055    }
4056
4057    #[test]
4058    fn different_port_is_not_same_origin_by_default() {
4059        assert!(!should_follow_redirect(
4060            &url("https://example.com:8443/mcp"),
4061            &url("https://example.com:9443/mcp"),
4062            false,
4063        ));
4064    }
4065
4066    #[test]
4067    fn default_port_matches_an_explicit_default_port() {
4068        // `https` defaults to 443, so an explicit `:443` is the same origin
4069        // as one with the port omitted.
4070        assert!(should_follow_redirect(
4071            &url("https://example.com/mcp"),
4072            &url("https://example.com:443/other"),
4073            false,
4074        ));
4075    }
4076
4077    #[test]
4078    fn cross_host_redirect_is_not_followed_by_default() {
4079        assert!(!should_follow_redirect(
4080            &url("https://example.com/mcp"),
4081            &url("https://attacker.example/mcp"),
4082            false,
4083        ));
4084    }
4085
4086    #[test]
4087    fn cross_host_redirect_is_followed_when_opted_in() {
4088        assert!(should_follow_redirect(
4089            &url("https://example.com/mcp"),
4090            &url("https://attacker.example/mcp"),
4091            true,
4092        ));
4093    }
4094
4095    #[test]
4096    fn https_to_http_downgrade_is_never_followed_even_when_opted_in() {
4097        assert!(!should_follow_redirect(
4098            &url("https://example.com/mcp"),
4099            &url("http://example.com/mcp"),
4100            false,
4101        ));
4102        assert!(!should_follow_redirect(
4103            &url("https://example.com/mcp"),
4104            &url("http://attacker.example/mcp"),
4105            true,
4106        ));
4107    }
4108
4109    #[test]
4110    fn http_to_https_redirect_is_not_a_downgrade() {
4111        // Not a downgrade, but still a scheme change, so it needs the same
4112        // opt-in as any other cross-origin redirect.
4113        assert!(!should_follow_redirect(
4114            &url("http://example.com/mcp"),
4115            &url("https://example.com/mcp"),
4116            false,
4117        ));
4118        assert!(should_follow_redirect(
4119            &url("http://example.com/mcp"),
4120            &url("https://example.com/mcp"),
4121            true,
4122        ));
4123    }
4124}