Skip to main content

av_harness/
mcp_client.rs

1//! MCP Streamable HTTP client for backends configured with
2//! `transport = "mcp"` (the default for `[[backends]]`).
3//!
4//! The gateway speaks to each backend the way a conforming MCP client does
5//! ([Streamable HTTP transport, 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)):
6//!
7//! - every POST carries `Accept: application/json, text/event-stream`;
8//! - the first request of a backend session is `initialize`, followed by
9//!   `notifications/initialized`; a returned `MCP-Session-Id` is sent on
10//!   every later request together with `MCP-Protocol-Version`;
11//! - a `404` on a request that carried a session id means the backend
12//!   ended the session: the client starts a new one and retries once
13//!   (the backend did not process the request, so the retry cannot run
14//!   a tool twice);
15//! - a request may be answered with one JSON object or with an SSE
16//!   stream. The stream is decoded incrementally and reading stops at the
17//!   JSON-RPC response whose `id` matches the request. Server-to-client
18//!   requests that arrive first are answered (`ping` with an empty result,
19//!   anything else with "method not found"), because the gateway declares
20//!   no client capabilities and a waiting backend would otherwise hang
21//!   until the deadline. A stream that ends early after carrying an event
22//!   id is resumed with `GET` and `Last-Event-ID`.
23//!
24//! Backend sessions are kept per (agent session, backend) pair so state a
25//! backend keeps per session never leaks between agents. The table is
26//! bounded; an evicted entry only costs a fresh `initialize`.
27
28use axum::http::{HeaderName, HeaderValue};
29use futures::StreamExt as _;
30use serde_json::{json, Value};
31use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::Instant;
34
35/// Protocol versions this gateway implements, newest first.
36pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 4] = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
37
38/// The version offered in `initialize` and preferred in negotiation.
39pub const LATEST_PROTOCOL_VERSION: &str = "2025-11-25";
40
41/// `Accept` value every Streamable HTTP POST must carry.
42const ACCEPT_BOTH: &str = "application/json, text/event-stream";
43
44/// Header carrying the backend-assigned (or gateway-assigned) session id.
45pub const SESSION_ID_HEADER: &str = "mcp-session-id";
46
47/// Header carrying the negotiated protocol version on later requests.
48pub const PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version";
49
50/// Upper bound on cached backend sessions.
51const MAX_BACKEND_SESSIONS: usize = 4096;
52
53/// Upper bound on bytes read from one backend exchange, matching the
54/// tool-response ceiling used by the plain JSON-RPC transport.
55pub(crate) const MAX_MCP_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
56
57/// Upper bound on `tools/list` pages and tools collected per backend.
58const MAX_DISCOVERY_PAGES: usize = 32;
59const MAX_DISCOVERED_TOOLS: usize = 2048;
60
61/// Stream resumptions attempted after an early end of an SSE stream.
62const MAX_RESUMPTIONS: usize = 3;
63
64/// Longest wait honored from a backend's SSE `retry` field.
65const MAX_RETRY_WAIT_MS: u64 = 5_000;
66
67/// True for a protocol version string this gateway implements.
68pub fn is_supported_version(version: &str) -> bool {
69    SUPPORTED_PROTOCOL_VERSIONS.contains(&version)
70}
71
72/// A negotiated backend session.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct BackendSession {
75    /// `MCP-Session-Id` returned by the backend, when it assigned one.
76    pub id: Option<String>,
77    /// Protocol version the backend answered `initialize` with.
78    pub protocol_version: String,
79}
80
81/// Why a backend exchange failed. The distinction matters to the caller:
82/// only [`McpError::NotDelivered`] proves the backend never processed the
83/// request, so only that failure may release a claimed tool execution.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum McpError {
86    /// The request never reached the backend (connect failure, or the
87    /// handshake that precedes it failed).
88    NotDelivered(String),
89    /// The backend received the request, but its answer is unusable.
90    AfterDelivery(String),
91}
92
93impl std::fmt::Display for McpError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Self::NotDelivered(detail) | Self::AfterDelivery(detail) => f.write_str(detail),
97        }
98    }
99}
100
101/// Everything needed to talk to one backend endpoint.
102#[derive(Clone)]
103pub struct BackendEndpoint {
104    /// Backend MCP endpoint URL (from configuration only).
105    pub url: String,
106    /// Credential header for this call, already marked sensitive.
107    pub auth: Option<(HeaderName, HeaderValue)>,
108    /// Extra headers (for example the per-call intent token).
109    pub extra: Vec<(HeaderName, HeaderValue)>,
110    /// Absolute deadline for the whole exchange, resumptions included.
111    pub deadline: tokio::time::Instant,
112}
113
114impl BackendEndpoint {
115    fn remaining(&self) -> std::time::Duration {
116        self.deadline
117            .saturating_duration_since(tokio::time::Instant::now())
118            .max(std::time::Duration::from_millis(1))
119    }
120
121    fn post(&self, client: &reqwest::Client, session: Option<&BackendSession>) -> reqwest::RequestBuilder {
122        let request = client
123            .post(&self.url)
124            .timeout(self.remaining())
125            .header(axum::http::header::CONTENT_TYPE, "application/json")
126            .header(axum::http::header::ACCEPT, ACCEPT_BOTH);
127        self.decorate(request, session)
128    }
129
130    fn decorate(
131        &self,
132        mut request: reqwest::RequestBuilder,
133        session: Option<&BackendSession>,
134    ) -> reqwest::RequestBuilder {
135        if let Some(session) = session {
136            request = request.header(PROTOCOL_VERSION_HEADER, session.protocol_version.as_str());
137            if let Some(id) = &session.id {
138                request = request.header(SESSION_ID_HEADER, id.as_str());
139            }
140        }
141        if let Some((name, value)) = &self.auth {
142            request = request.header(name.clone(), value.clone());
143        }
144        for (name, value) in &self.extra {
145            request = request.header(name.clone(), value.clone());
146        }
147        request
148    }
149}
150
151fn classify(error: &reqwest::Error) -> McpError {
152    let category = crate::pipeline::classify_upstream_error(error).to_owned();
153    tracing::warn!(
154        category = %category,
155        error.status = ?error.status(),
156        error.is_timeout = error.is_timeout(),
157        error.is_connect = error.is_connect(),
158        "MCP backend request failed"
159    );
160    if error.is_connect() {
161        McpError::NotDelivered(category)
162    } else {
163        McpError::AfterDelivery(category)
164    }
165}
166
167/// Read and validate the `MCP-Session-Id` a backend returned.
168fn session_id_of(response: &reqwest::Response) -> Result<Option<String>, String> {
169    let mut values = response.headers().get_all(SESSION_ID_HEADER).iter();
170    let Some(value) = values.next() else {
171        return Ok(None);
172    };
173    if values.next().is_some() {
174        return Err("backend returned more than one MCP-Session-Id".to_owned());
175    }
176    let text = value
177        .to_str()
178        .map_err(|_| "backend MCP-Session-Id is not visible ASCII".to_owned())?;
179    if text.is_empty() || text.len() > 1024 || !text.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
180        return Err("backend MCP-Session-Id must be 1-1024 visible ASCII characters".to_owned());
181    }
182    Ok(Some(text.to_owned()))
183}
184
185fn is_event_stream(response: &reqwest::Response) -> bool {
186    response
187        .headers()
188        .get(axum::http::header::CONTENT_TYPE)
189        .and_then(|value| value.to_str().ok())
190        .and_then(|value| value.split(';').next())
191        .is_some_and(|media| media.trim().eq_ignore_ascii_case("text/event-stream"))
192}
193
194/// Incremental decoder for `text/event-stream` bodies, following the
195/// WHATWG event-stream interpretation rules that MCP relies on.
196#[derive(Debug, Default)]
197pub struct SseDecoder {
198    buffer: Vec<u8>,
199    data: String,
200    has_data: bool,
201    /// Last `id` field seen, used for `Last-Event-ID` on resumption.
202    pub last_event_id: Option<String>,
203    /// Last `retry` field seen, in milliseconds.
204    pub retry_ms: Option<u64>,
205}
206
207impl SseDecoder {
208    /// Feed a chunk and return the `data` payload of every completed event.
209    pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<String>, String> {
210        self.buffer.extend_from_slice(chunk);
211        if self.buffer.len() > MAX_MCP_RESPONSE_BYTES {
212            return Err(format!(
213                "event stream line exceeds {MAX_MCP_RESPONSE_BYTES} bytes"
214            ));
215        }
216        let mut events = Vec::new();
217        while let Some(end) = self.buffer.iter().position(|b| *b == b'\n' || *b == b'\r') {
218            let terminator = self.buffer.get(end).copied();
219            let mut consumed = end.saturating_add(1);
220            if terminator == Some(b'\r') {
221                match self.buffer.get(end.saturating_add(1)) {
222                    Some(b'\n') => consumed = end.saturating_add(2),
223                    Some(_) => {}
224                    // A trailing CR may be the first half of CRLF.
225                    None => break,
226                }
227            }
228            let line: Vec<u8> = self.buffer.drain(..consumed).take(end).collect();
229            let line = String::from_utf8(line).map_err(|_| "event stream is not valid UTF-8".to_owned())?;
230            if let Some(event) = self.line(&line) {
231                events.push(event);
232            }
233        }
234        Ok(events)
235    }
236
237    fn line(&mut self, line: &str) -> Option<String> {
238        if line.is_empty() {
239            if !self.has_data {
240                return None;
241            }
242            self.has_data = false;
243            let mut data = std::mem::take(&mut self.data);
244            if data.ends_with('\n') {
245                data.pop();
246            }
247            return Some(data);
248        }
249        if line.starts_with(':') {
250            return None;
251        }
252        let (field, value) = match line.split_once(':') {
253            Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
254            None => (line, ""),
255        };
256        match field {
257            "data" => {
258                self.data.push_str(value);
259                self.data.push('\n');
260                self.has_data = true;
261            }
262            "id" if !value.contains('\0') => self.last_event_id = Some(value.to_owned()),
263            "retry" if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => {
264                self.retry_ms = value.parse().ok();
265            }
266            _ => {}
267        }
268        None
269    }
270}
271
272/// What one decoded message means for a pending request.
273enum Message {
274    /// The response to the pending request, re-serialized.
275    Response(Vec<u8>),
276    /// A request from the backend that needs an answer.
277    ServerRequest { id: Value, method: String },
278    /// Anything else (notifications, unrelated responses, priming events).
279    Other,
280}
281
282fn interpret(payload: &str, request_id: &Value) -> Result<Vec<Message>, String> {
283    if payload.trim().is_empty() {
284        return Ok(vec![Message::Other]);
285    }
286    let value: Value =
287        serde_json::from_str(payload).map_err(|_| "event stream carried a non-JSON message".to_owned())?;
288    let items = match value {
289        Value::Array(items) => items,
290        other => vec![other],
291    };
292    let mut messages = Vec::with_capacity(items.len());
293    for item in items {
294        let Some(object) = item.as_object() else {
295            messages.push(Message::Other);
296            continue;
297        };
298        let method = object.get("method").and_then(Value::as_str);
299        let id = object.get("id");
300        match (method, id) {
301            (Some(method), Some(id)) => messages.push(Message::ServerRequest {
302                id: id.clone(),
303                method: method.to_owned(),
304            }),
305            (None, Some(id))
306                if id == request_id && (object.contains_key("result") || object.contains_key("error")) =>
307            {
308                messages.push(Message::Response(
309                    serde_json::to_vec(&item).map_err(|error| error.to_string())?,
310                ));
311            }
312            _ => messages.push(Message::Other),
313        }
314    }
315    Ok(messages)
316}
317
318/// Client for MCP backends, holding the bounded backend-session table.
319pub struct McpBackendClient {
320    client: reqwest::Client,
321    sessions: parking_lot::Mutex<HashMap<(String, String), SessionSlot>>,
322}
323
324struct SessionSlot {
325    session: Arc<tokio::sync::Mutex<Option<BackendSession>>>,
326    last_used: Instant,
327}
328
329impl McpBackendClient {
330    /// Build a client on top of the gateway's shared HTTP client.
331    pub fn new(client: reqwest::Client) -> Self {
332        Self {
333            client,
334            sessions: parking_lot::Mutex::new(HashMap::new()),
335        }
336    }
337
338    /// Number of cached backend sessions (for tests and metrics).
339    pub fn cached_sessions(&self) -> usize {
340        self.sessions.lock().len()
341    }
342
343    fn slot(&self, agent_session: &str, backend: &str) -> Arc<tokio::sync::Mutex<Option<BackendSession>>> {
344        let mut sessions = self.sessions.lock();
345        let key = (agent_session.to_owned(), backend.to_owned());
346        if let Some(slot) = sessions.get_mut(&key) {
347            slot.last_used = Instant::now();
348            return Arc::clone(&slot.session);
349        }
350        if sessions.len() >= MAX_BACKEND_SESSIONS {
351            // Evict idle slots, oldest first, until there is room. A slot
352            // still referenced by an in-flight call is never evicted.
353            let mut idle: Vec<((String, String), Instant)> = sessions
354                .iter()
355                .filter(|(_, slot)| Arc::strong_count(&slot.session) == 1)
356                .map(|(key, slot)| (key.clone(), slot.last_used))
357                .collect();
358            idle.sort_by_key(|(_, used)| *used);
359            let excess = sessions
360                .len()
361                .saturating_add(1)
362                .saturating_sub(MAX_BACKEND_SESSIONS);
363            for (key, _) in idle.into_iter().take(excess.max(1)) {
364                sessions.remove(&key);
365            }
366        }
367        let session = Arc::new(tokio::sync::Mutex::new(None));
368        sessions.insert(
369            key,
370            SessionSlot {
371                session: Arc::clone(&session),
372                last_used: Instant::now(),
373            },
374        );
375        session
376    }
377
378    /// Forget every backend session opened for `agent_session`.
379    pub fn forget_agent_session(&self, agent_session: &str) {
380        self.sessions
381            .lock()
382            .retain(|(agent, _), _| agent != agent_session);
383    }
384
385    /// Run the `initialize` handshake against `endpoint`.
386    pub async fn initialize(&self, endpoint: &BackendEndpoint) -> Result<BackendSession, McpError> {
387        let request_id = Value::String(format!("agentvisor-initialize-{}", av_core::ids::new_event_uid()));
388        let body = json!({
389            "jsonrpc": "2.0",
390            "id": request_id,
391            "method": "initialize",
392            "params": {
393                "protocolVersion": LATEST_PROTOCOL_VERSION,
394                "capabilities": {},
395                "clientInfo": {"name": "agentvisor-gateway", "version": "undisclosed"},
396            },
397        });
398        let bytes = serde_json::to_vec(&body).map_err(|error| McpError::NotDelivered(error.to_string()))?;
399        let response = endpoint
400            .post(&self.client, None)
401            .body(bytes)
402            .send()
403            .await
404            // Only the handshake can have reached the backend, so the tool
405            // call it precedes was not delivered in either case.
406            .map_err(|error| McpError::NotDelivered(format!("initialize: {}", classify(&error))))?;
407        let status = response.status();
408        if !status.is_success() {
409            return Err(McpError::NotDelivered(format!(
410                "backend refused initialize with HTTP {}",
411                status.as_u16()
412            )));
413        }
414        let assigned = session_id_of(&response).map_err(McpError::NotDelivered)?;
415        let provisional = BackendSession {
416            id: assigned.clone(),
417            protocol_version: LATEST_PROTOCOL_VERSION.to_owned(),
418        };
419        let reply = self
420            .read_reply(response, &request_id, endpoint, Some(&provisional))
421            .await
422            .map_err(|error| McpError::NotDelivered(format!("initialize: {error}")))?;
423        let reply: Value = serde_json::from_slice(&reply)
424            .map_err(|_| McpError::NotDelivered("initialize reply is not JSON".to_owned()))?;
425        if let Some(error) = reply.get("error") {
426            let message = error.get("message").and_then(Value::as_str).unwrap_or("error");
427            return Err(McpError::NotDelivered(format!(
428                "backend rejected initialize: {message}"
429            )));
430        }
431        let version = reply
432            .pointer("/result/protocolVersion")
433            .and_then(Value::as_str)
434            .ok_or_else(|| McpError::NotDelivered("initialize result has no protocolVersion".to_owned()))?;
435        if !is_supported_version(version) {
436            return Err(McpError::NotDelivered(format!(
437                "backend negotiated unsupported MCP protocol version {version:?}"
438            )));
439        }
440        let session = BackendSession {
441            id: assigned,
442            protocol_version: version.to_owned(),
443        };
444        let notification =
445            serde_json::to_vec(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}))
446                .map_err(|error| McpError::NotDelivered(error.to_string()))?;
447        let acknowledged = endpoint
448            .post(&self.client, Some(&session))
449            .body(notification)
450            .send()
451            .await
452            .map_err(|error| {
453                McpError::NotDelivered(format!("initialized notification: {}", classify(&error)))
454            })?;
455        if !acknowledged.status().is_success() {
456            return Err(McpError::NotDelivered(format!(
457                "backend refused the initialized notification with HTTP {}",
458                acknowledged.status().as_u16()
459            )));
460        }
461        Ok(session)
462    }
463
464    /// The cached session for (`agent_session`, `backend`), opening one on
465    /// first use. Concurrent callers share a single handshake.
466    pub async fn session(
467        &self,
468        agent_session: &str,
469        backend: &str,
470        endpoint: &BackendEndpoint,
471    ) -> Result<BackendSession, McpError> {
472        let slot = self.slot(agent_session, backend);
473        let mut guard = slot.lock().await;
474        if let Some(session) = guard.as_ref() {
475            return Ok(session.clone());
476        }
477        let session = self.initialize(endpoint).await?;
478        *guard = Some(session.clone());
479        Ok(session)
480    }
481
482    /// Drop a session the backend reported as ended, unless a concurrent
483    /// caller already replaced it.
484    async fn invalidate(&self, agent_session: &str, backend: &str, stale: &BackendSession) {
485        let slot = self.slot(agent_session, backend);
486        let mut guard = slot.lock().await;
487        if guard.as_ref() == Some(stale) {
488            *guard = None;
489        }
490    }
491
492    /// Send one JSON-RPC request and return the matching JSON-RPC response
493    /// bytes. `body` is forwarded verbatim; `request_id` is its `id`.
494    ///
495    /// Returns `(http_status, response_bytes)`. A non-success HTTP status
496    /// other than a session-expiry `404` is returned as-is with the body
497    /// the backend sent, so the caller can record it.
498    pub async fn request(
499        &self,
500        agent_session: &str,
501        backend: &str,
502        endpoint: &BackendEndpoint,
503        body: &[u8],
504        request_id: &Value,
505    ) -> Result<(u16, Vec<u8>, Option<String>), McpError> {
506        let mut session = self.session(agent_session, backend, endpoint).await?;
507        let mut retried = false;
508        loop {
509            let response = endpoint
510                .post(&self.client, Some(&session))
511                .body(body.to_vec())
512                .send()
513                .await
514                .map_err(|error| classify(&error))?;
515            let status = response.status();
516            if status == reqwest::StatusCode::NOT_FOUND && session.id.is_some() && !retried {
517                // Spec: 404 on a request carrying a session id means the
518                // session is gone and the request was not processed.
519                retried = true;
520                self.invalidate(agent_session, backend, &session).await;
521                session = self.session(agent_session, backend, endpoint).await?;
522                continue;
523            }
524            if let Some(refused) = refused_encoding(&response) {
525                return Err(McpError::AfterDelivery(refused));
526            }
527            if !status.is_success() {
528                let content_type = response
529                    .headers()
530                    .get(axum::http::header::CONTENT_TYPE)
531                    .and_then(|value| value.to_str().ok())
532                    .map(str::to_owned);
533                let bytes = read_bounded(response).await.map_err(McpError::AfterDelivery)?;
534                return Ok((status.as_u16(), bytes, content_type));
535            }
536            let bytes = self
537                .read_reply(response, request_id, endpoint, Some(&session))
538                .await
539                .map_err(McpError::AfterDelivery)?;
540            return Ok((status.as_u16(), bytes, Some("application/json".to_owned())));
541        }
542    }
543
544    /// Collect every tool a backend advertises through `tools/list`,
545    /// following `nextCursor` pagination within fixed bounds.
546    pub async fn list_tools(
547        &self,
548        agent_session: &str,
549        backend: &str,
550        endpoint: &BackendEndpoint,
551    ) -> Result<Vec<Value>, McpError> {
552        let mut tools = Vec::new();
553        let mut cursor: Option<String> = None;
554        for page in 0..MAX_DISCOVERY_PAGES {
555            let request_id = Value::String(format!(
556                "agentvisor-tools-list-{page}-{}",
557                av_core::ids::new_event_uid()
558            ));
559            let mut request =
560                json!({"jsonrpc": "2.0", "id": request_id, "method": "tools/list", "params": {}});
561            if let (Some(cursor), Some(params)) = (&cursor, request.get_mut("params")) {
562                params["cursor"] = Value::String(cursor.clone());
563            }
564            let body =
565                serde_json::to_vec(&request).map_err(|error| McpError::NotDelivered(error.to_string()))?;
566            let (status, bytes, _) = self
567                .request(agent_session, backend, endpoint, &body, &request_id)
568                .await?;
569            if !(200..300).contains(&status) {
570                return Err(McpError::AfterDelivery(format!(
571                    "tools/list answered HTTP {status}"
572                )));
573            }
574            let reply: Value = serde_json::from_slice(&bytes)
575                .map_err(|_| McpError::AfterDelivery("tools/list reply is not JSON".to_owned()))?;
576            if reply.get("error").is_some() {
577                return Err(McpError::AfterDelivery("backend rejected tools/list".to_owned()));
578            }
579            let page_tools = reply
580                .pointer("/result/tools")
581                .and_then(Value::as_array)
582                .ok_or_else(|| McpError::AfterDelivery("tools/list result has no tools array".to_owned()))?;
583            for tool in page_tools {
584                if tools.len() >= MAX_DISCOVERED_TOOLS {
585                    return Ok(tools);
586                }
587                tools.push(tool.clone());
588            }
589            cursor = reply
590                .pointer("/result/nextCursor")
591                .and_then(Value::as_str)
592                .filter(|next| !next.is_empty())
593                .map(str::to_owned);
594            if cursor.is_none() {
595                return Ok(tools);
596            }
597        }
598        Ok(tools)
599    }
600
601    /// Read the JSON-RPC response for `request_id` from a successful reply,
602    /// which is either one JSON object or an SSE stream.
603    async fn read_reply(
604        &self,
605        response: reqwest::Response,
606        request_id: &Value,
607        endpoint: &BackendEndpoint,
608        session: Option<&BackendSession>,
609    ) -> Result<Vec<u8>, String> {
610        if !is_event_stream(&response) {
611            return read_bounded(response).await;
612        }
613        let mut decoder = SseDecoder::default();
614        let mut response = response;
615        let mut total = 0_usize;
616        let mut resumptions = 0_usize;
617        loop {
618            let mut stream = response.bytes_stream();
619            while let Some(chunk) = stream.next().await {
620                let chunk =
621                    chunk.map_err(|error| crate::pipeline::classify_upstream_error(&error).to_owned())?;
622                total = total.saturating_add(chunk.len());
623                if total > MAX_MCP_RESPONSE_BYTES {
624                    return Err(format!("backend response exceeds {MAX_MCP_RESPONSE_BYTES} bytes"));
625                }
626                for payload in decoder.push(&chunk)? {
627                    for message in interpret(&payload, request_id)? {
628                        match message {
629                            Message::Response(bytes) => return Ok(bytes),
630                            Message::ServerRequest { id, method } => {
631                                self.answer_server_request(endpoint, session, id, &method).await;
632                            }
633                            Message::Other => {}
634                        }
635                    }
636                }
637            }
638            let Some(last_event_id) = decoder.last_event_id.clone() else {
639                return Err("backend event stream ended without a response".to_owned());
640            };
641            if resumptions >= MAX_RESUMPTIONS {
642                return Err("backend event stream ended without a response after resumption".to_owned());
643            }
644            resumptions = resumptions.saturating_add(1);
645            let wait = decoder.retry_ms.unwrap_or(0).min(MAX_RETRY_WAIT_MS);
646            if wait > 0 {
647                tokio::time::sleep(std::time::Duration::from_millis(wait)).await;
648            }
649            let resume = endpoint
650                .decorate(
651                    self.client
652                        .get(&endpoint.url)
653                        .timeout(endpoint.remaining())
654                        .header(axum::http::header::ACCEPT, "text/event-stream")
655                        .header("last-event-id", last_event_id.as_str()),
656                    session,
657                )
658                .send()
659                .await
660                .map_err(|error| crate::pipeline::classify_upstream_error(&error).to_owned())?;
661            if !resume.status().is_success() || !is_event_stream(&resume) {
662                return Err(format!(
663                    "backend refused to resume its event stream (HTTP {})",
664                    resume.status().as_u16()
665                ));
666            }
667            response = resume;
668        }
669    }
670
671    /// Answer a server-to-client request so the backend does not wait for
672    /// a reply that would never come. Best effort: failures are logged.
673    async fn answer_server_request(
674        &self,
675        endpoint: &BackendEndpoint,
676        session: Option<&BackendSession>,
677        id: Value,
678        method: &str,
679    ) {
680        let reply = if method == "ping" {
681            json!({"jsonrpc": "2.0", "id": id, "result": {}})
682        } else {
683            tracing::warn!(method, "MCP backend sent a request the gateway does not support");
684            json!({
685                "jsonrpc": "2.0",
686                "id": id,
687                "error": {"code": -32601, "message": "method not supported by the AgentVisor gateway"},
688            })
689        };
690        let Ok(bytes) = serde_json::to_vec(&reply) else {
691            return;
692        };
693        if let Err(error) = endpoint.post(&self.client, session).body(bytes).send().await {
694            let _ = classify(&error);
695        }
696    }
697}
698
699fn refused_encoding(response: &reqwest::Response) -> Option<String> {
700    for value in response.headers().get_all(axum::http::header::CONTENT_ENCODING) {
701        let raw = value.to_str().unwrap_or_default();
702        for token in raw.split(',') {
703            let token = token.trim();
704            if !token.is_empty() && !token.eq_ignore_ascii_case("identity") {
705                return Some(format!(
706                    "tool upstream responded with unsupported Content-Encoding token {token:?} (full header: {raw:?})"
707                ));
708            }
709        }
710    }
711    None
712}
713
714async fn read_bounded(response: reqwest::Response) -> Result<Vec<u8>, String> {
715    let mut stream = response.bytes_stream();
716    let mut body = Vec::new();
717    while let Some(chunk) = stream.next().await {
718        let chunk = chunk.map_err(|error| crate::pipeline::classify_upstream_error(&error).to_owned())?;
719        if body.len().saturating_add(chunk.len()) > MAX_MCP_RESPONSE_BYTES {
720            return Err(format!("backend response exceeds {MAX_MCP_RESPONSE_BYTES} bytes"));
721        }
722        body.extend_from_slice(&chunk);
723    }
724    Ok(body)
725}
726
727#[cfg(test)]
728mod tests {
729    #![allow(
730        clippy::unwrap_used,
731        clippy::expect_used,
732        clippy::panic,
733        clippy::indexing_slicing
734    )]
735
736    use super::*;
737
738    #[test]
739    fn decoder_joins_multiline_data_and_tracks_ids() {
740        let mut decoder = SseDecoder::default();
741        let events = decoder
742            .push(b": comment\r\nid: 7\r\nretry: 250\r\ndata: {\"a\":\r\ndata: 1}\r\n\r\n")
743            .unwrap();
744        assert_eq!(events, vec!["{\"a\":\n1}".to_owned()]);
745        assert_eq!(decoder.last_event_id.as_deref(), Some("7"));
746        assert_eq!(decoder.retry_ms, Some(250));
747    }
748
749    #[test]
750    fn decoder_handles_split_crlf_and_lone_cr() {
751        let mut decoder = SseDecoder::default();
752        assert!(decoder.push(b"data: one\r").unwrap().is_empty());
753        let events = decoder.push(b"\n\rdata: two\r\r").unwrap();
754        assert_eq!(events, vec!["one".to_owned()]);
755        let events = decoder.push(b"x").unwrap();
756        assert_eq!(events, vec!["two".to_owned()]);
757    }
758
759    #[test]
760    fn empty_priming_event_is_ignored() {
761        let mut decoder = SseDecoder::default();
762        let events = decoder.push(b"id: 1\ndata:\n\n").unwrap();
763        assert_eq!(events, vec![String::new()]);
764        assert!(matches!(
765            interpret(&events[0], &json!(1)).unwrap()[0],
766            Message::Other
767        ));
768    }
769
770    #[test]
771    fn interpret_finds_matching_response_and_server_requests() {
772        let id = json!(5);
773        let messages = interpret(
774            r#"[{"jsonrpc":"2.0","method":"notifications/progress","params":{}},
775                {"jsonrpc":"2.0","id":"s1","method":"ping"},
776                {"jsonrpc":"2.0","id":4,"result":{}},
777                {"jsonrpc":"2.0","id":5,"result":{"ok":true}}]"#,
778            &id,
779        )
780        .unwrap();
781        assert!(matches!(messages[0], Message::Other));
782        assert!(matches!(&messages[1], Message::ServerRequest { method, .. } if method == "ping"));
783        assert!(
784            matches!(messages[2], Message::Other),
785            "a response with another id is not ours"
786        );
787        match &messages[3] {
788            Message::Response(bytes) => {
789                let value: Value = serde_json::from_slice(bytes).unwrap();
790                assert_eq!(value["result"]["ok"], true);
791            }
792            _ => panic!("expected the matching response"),
793        }
794    }
795
796    #[test]
797    fn non_json_event_is_an_error() {
798        assert!(interpret("not json", &json!(1)).is_err());
799    }
800
801    #[test]
802    fn supported_versions_include_latest() {
803        assert!(is_supported_version(LATEST_PROTOCOL_VERSION));
804        assert!(is_supported_version("2025-03-26"));
805        assert!(!is_supported_version("1.0.0"));
806    }
807}