Skip to main content

oxicode_agent/mcp/transport/
http.rs

1//! Streamable HTTP transport for MCP (spec 2025-03-26).
2//!
3//! Speaks the current Streamable HTTP transport:
4//! - POST JSON-RPC messages to a single MCP endpoint; the server replies
5//!   with either `Content-Type: application/json` (single response) or
6//!   `Content-Type: text/event-stream` (SSE stream of one or more
7//!   messages, the first matching id being our response).
8//! - `Mcp-Session-Id` is captured from the `initialize` response and
9//!   attached to all subsequent requests and notifications.
10//! - DELETE terminates the session.
11//!
12//! v2.1 deliberately omits the dedicated server-push SSE listener
13//! (GET on the MCP endpoint). Server-push messages that arrive *on a
14//! POST SSE response stream* (the common path) are still dispatched to
15//! the inbound handler inline during request correlation. A background
16//! GET listener that survives across requests would require
17//! `Arc<Self>` plumbing through `Box<dyn McpTransport>` and is deferred
18//! — see `docs/designs/2026-06-19-mcp-v2-conformance-transports.md` §4.2.
19//!
20//! Authentication is delegated to an optional [`McpCredentialProvider`]:
21//! the transport injects `Authorization: Bearer …` on every request and,
22//! on `401`/`403`, calls [`McpCredentialProvider::refresh`] once and
23//! retries the request with the newly returned token.
24
25use super::{InboundHandler, McpTransport};
26use crate::mcp::auth::{Credential, McpCredentialProvider};
27use crate::mcp::types::RawJsonRpcMessage;
28use anyhow::{Context, Result};
29use futures::StreamExt;
30use reqwest::Client;
31use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::Duration;
34use tokio::sync::{Mutex, oneshot};
35
36/// Default per-request timeout (milliseconds) when `ServerEntry::timeout`
37/// is `None`.
38pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
39
40/// Streamable HTTP transport.
41pub struct StreamableHttpTransport {
42    endpoint: String,
43    server_name: String,
44    client: Client,
45    /// Session id captured from the `initialize` response; attached to
46    /// every subsequent request header.
47    session_id: Mutex<Option<String>>,
48    /// Inbound handler for notifications and server→client requests.
49    /// Uses `parking_lot` (sync) so the sync [`Self::set_inbound_handler`]
50    /// setter can write without `async` plumbing; the guard is dropped
51    /// before any `.await` so the `!Send` constraint is safe.
52    inbound_handler: parking_lot::Mutex<Option<InboundHandler>>,
53    /// Reserved for future use (e.g. background GET listener).
54    #[allow(dead_code)]
55    pending: Mutex<HashMap<u64, oneshot::Sender<RawJsonRpcMessage>>>,
56    credential_provider: Option<Arc<dyn McpCredentialProvider>>,
57    timeout: Duration,
58    closed: Mutex<bool>,
59}
60
61impl std::fmt::Debug for StreamableHttpTransport {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("StreamableHttpTransport")
64            .field("endpoint", &self.endpoint)
65            .field("server_name", &self.server_name)
66            .field("connected", &self.is_connected())
67            .finish()
68    }
69}
70
71impl StreamableHttpTransport {
72    /// Build a new transport.
73    ///
74    /// `credential_provider` is queried for `Authorization` headers on
75    /// every request; pass `None` to disable authentication.
76    /// `timeout_ms == 0` disables the client-side per-request timeout.
77    pub fn new(
78        server_name: &str,
79        endpoint: &str,
80        credential_provider: Option<Arc<dyn McpCredentialProvider>>,
81        timeout_ms: u64,
82    ) -> Result<Self> {
83        let client = Client::builder()
84            .user_agent(concat!("oxicode-mcp/", env!("CARGO_PKG_VERSION")))
85            .build()
86            .context("Failed to build reqwest client for MCP Streamable HTTP")?;
87        Ok(Self {
88            endpoint: endpoint.to_string(),
89            server_name: server_name.to_string(),
90            client,
91            session_id: Mutex::new(None),
92            inbound_handler: parking_lot::Mutex::new(None),
93            pending: Mutex::new(HashMap::new()),
94            credential_provider,
95            timeout: if timeout_ms == 0 {
96                Duration::from_secs(60 * 60 * 24 * 365)
97            } else {
98                Duration::from_millis(timeout_ms)
99            },
100            closed: Mutex::new(false),
101        })
102    }
103
104    /// Build the standard request headers (Accept, session id, auth).
105    async fn build_headers(&self, credential: Option<&Credential>) -> reqwest::header::HeaderMap {
106        let mut headers = reqwest::header::HeaderMap::new();
107        headers.insert(
108            "Accept",
109            // SAFETY: the Accept value is a compile-time literal with no
110            // invalid header characters; `parse` cannot fail.
111            #[allow(clippy::expect_used)]
112            "application/json, text/event-stream"
113                .parse()
114                .expect("static Accept header is valid"),
115        );
116        if let Some(sid) = self.session_id.lock().await.as_deref()
117            && let Ok(v) = sid.parse()
118        {
119            headers.insert("Mcp-Session-Id", v);
120        }
121        if let Some(cred) = credential
122            && let Ok(v) = format!("Bearer {}", cred.access_token).parse()
123        {
124            headers.insert(reqwest::header::AUTHORIZATION, v);
125        }
126        headers
127    }
128
129    /// Capture `Mcp-Session-Id` from a response header if present.
130    async fn capture_session_id(&self, resp: &reqwest::Response) {
131        if let Some(v) = resp.headers().get("Mcp-Session-Id")
132            && let Ok(s) = v.to_str()
133        {
134            *self.session_id.lock().await = Some(s.to_string());
135        }
136    }
137
138    /// POST `json` once and return the response.
139    async fn post_once(
140        &self,
141        json: &str,
142        credential: Option<&Credential>,
143    ) -> Result<reqwest::Response> {
144        let headers = self.build_headers(credential).await;
145        self.client
146            .post(&self.endpoint)
147            .headers(headers)
148            .header("Content-Type", "application/json")
149            .body(json.to_string())
150            .send()
151            .await
152            .context("MCP Streamable HTTP POST failed")
153    }
154
155    /// Dispatch an inbound message (notification or server→client
156    /// request) to the installed handler.
157    fn dispatch_inbound(&self, msg: RawJsonRpcMessage) {
158        if let Some(h) = self.inbound_handler.lock().as_mut() {
159            h(msg);
160        }
161    }
162}
163
164#[async_trait::async_trait]
165impl McpTransport for StreamableHttpTransport {
166    async fn request(&mut self, id: u64, json: &str) -> Result<RawJsonRpcMessage> {
167        if *self.closed.lock().await {
168            anyhow::bail!("MCP HTTP transport closed");
169        }
170
171        let credential = match self.credential_provider.as_ref() {
172            Some(p) => p.access_token(&self.server_name, &self.endpoint).await,
173            None => None,
174        };
175
176        let resp = self.post_once(json, credential.as_ref()).await?;
177        let status = resp.status();
178
179        // First 2xx response (the `initialize`) carries `Mcp-Session-Id`.
180        if status.is_success() {
181            self.capture_session_id(&resp).await;
182        }
183
184        // Refresh-on-401/403 with exactly one retry.
185        if (status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN)
186            && let Some(provider) = self.credential_provider.as_ref()
187        {
188            // Drop the failed response body.
189            drop(resp);
190            let refreshed = provider.refresh(&self.server_name, &self.endpoint).await;
191            let credential2 = match refreshed {
192                Some(_) => {
193                    provider
194                        .access_token(&self.server_name, &self.endpoint)
195                        .await
196                }
197                None => credential,
198            };
199            let resp2 = self.post_once(json, credential2.as_ref()).await?;
200            let status2 = resp2.status();
201            if status2.is_success() {
202                self.capture_session_id(&resp2).await;
203            }
204            if !status2.is_success() {
205                anyhow::bail!(
206                    "MCP HTTP request failed after credential refresh: {} {}",
207                    status2.as_u16(),
208                    status2.canonical_reason().unwrap_or("")
209                );
210            }
211            return self.handle_response(resp2, id).await;
212        }
213
214        if !status.is_success() {
215            anyhow::bail!(
216                "MCP HTTP error {}: {}",
217                status.as_u16(),
218                status.canonical_reason().unwrap_or("")
219            );
220        }
221
222        self.handle_response(resp, id).await
223    }
224
225    async fn notify(&mut self, json: &str) -> Result<()> {
226        if *self.closed.lock().await {
227            anyhow::bail!("MCP HTTP transport closed");
228        }
229        let credential = match self.credential_provider.as_ref() {
230            Some(p) => p.access_token(&self.server_name, &self.endpoint).await,
231            None => None,
232        };
233        let resp = self.post_once(json, credential.as_ref()).await?;
234        let status = resp.status();
235        if status.is_success() {
236            self.capture_session_id(&resp).await;
237        }
238        if !status.is_success() {
239            anyhow::bail!(
240                "MCP HTTP notify failed: {} {}",
241                status.as_u16(),
242                status.canonical_reason().unwrap_or("")
243            );
244        }
245        Ok(())
246    }
247
248    fn set_inbound_handler(&mut self, handler: InboundHandler) {
249        *self.inbound_handler.lock() = Some(handler);
250    }
251
252    async fn close(&mut self) -> Result<()> {
253        *self.closed.lock().await = true;
254        // Best-effort DELETE to terminate the session.
255        let sid = self.session_id.lock().await.clone();
256        if let Some(sid) = sid
257            && let Ok(v) = sid.parse::<reqwest::header::HeaderValue>()
258        {
259            let _ = self
260                .client
261                .delete(&self.endpoint)
262                .header("Mcp-Session-Id", v)
263                .send()
264                .await;
265        }
266        Ok(())
267    }
268
269    fn is_connected(&self) -> bool {
270        !*self.closed.blocking_lock()
271    }
272}
273
274impl StreamableHttpTransport {
275    /// Process a 2xx response and return the JSON-RPC message whose id
276    /// matches `id`. Any non-matching messages encountered (notifications
277    /// or server→client requests interleaved on the SSE stream) are
278    /// dispatched to the inbound handler.
279    async fn handle_response(&self, resp: reqwest::Response, id: u64) -> Result<RawJsonRpcMessage> {
280        let ct = resp
281            .headers()
282            .get(reqwest::header::CONTENT_TYPE)
283            .and_then(|v| v.to_str().ok())
284            .unwrap_or("")
285            .to_string();
286
287        if ct.starts_with("text/event-stream") {
288            let mut stream = resp.bytes_stream();
289            let mut buffer: Vec<u8> = Vec::new();
290            let timeout = self.timeout;
291            let resolved = tokio::time::timeout(timeout, async {
292                while let Some(chunk) = stream.next().await {
293                    let chunk = chunk.context("MCP HTTP SSE chunk read failed")?;
294                    buffer.extend_from_slice(&chunk);
295                    while let Some((event, consumed)) = parse_sse_event(&buffer) {
296                        let rest = buffer.split_off(consumed);
297                        buffer = rest;
298                        let Some(data) = event else { continue };
299                        let msg: RawJsonRpcMessage = match serde_json::from_slice(&data) {
300                            Ok(m) => m,
301                            Err(_) => continue,
302                        };
303                        if msg.id == Some(id) {
304                            return Ok(msg);
305                        }
306                        self.dispatch_inbound(msg);
307                    }
308                }
309                Err::<RawJsonRpcMessage, _>(anyhow::anyhow!(
310                    "MCP HTTP SSE response ended without matching id"
311                ))
312            })
313            .await
314            .map_err(|_| {
315                anyhow::anyhow!("MCP HTTP request timed out after {:?}", self.timeout)
316            })??;
317            return Ok(resolved);
318        }
319
320        // application/json (or missing/unknown): single JSON-RPC response.
321        let body = resp
322            .bytes()
323            .await
324            .context("Failed to read MCP HTTP response body")?;
325        let msg: RawJsonRpcMessage =
326            serde_json::from_slice(&body).context("Failed to parse MCP HTTP response JSON")?;
327        if msg.id != Some(id) {
328            self.dispatch_inbound(msg);
329            anyhow::bail!("MCP HTTP returned response with non-matching id");
330        }
331        Ok(msg)
332    }
333}
334
335/// Parse one SSE event from `buffer`. Returns
336/// `(Option<Vec<u8>>, usize)` where the `Option<Vec<u8>>` is the
337/// concatenated `data:` payload for the event (without the `data: `
338/// prefix) or `None` if the event had no data fields, and `usize` is
339/// the number of bytes consumed (including the trailing blank line).
340/// Returns `None` if `buffer` does not yet contain a complete event
341/// delimiter (`\n\n`).
342fn parse_sse_event(buffer: &[u8]) -> Option<(Option<Vec<u8>>, usize)> {
343    let delim = find_sse_delim(buffer)?;
344    let event_bytes = &buffer[..delim];
345    let mut data: Option<Vec<u8>> = None;
346    for line in event_bytes.split(|b| *b == b'\n') {
347        let line = line.strip_suffix(b"\r").unwrap_or(line);
348        if line.starts_with(b"data:") {
349            let rest = if line.len() > 4 && line[4] == b' ' {
350                &line[5..]
351            } else if line.len() > 4 {
352                &line[4..]
353            } else {
354                &[][..]
355            };
356            let entry = data.get_or_insert_with(Vec::new);
357            if !entry.is_empty() {
358                entry.push(b'\n');
359            }
360            entry.extend_from_slice(rest);
361        }
362        // `event:` and `id:` lines are ignored in v2.1.
363    }
364    Some((data, delim + 2))
365}
366
367fn find_sse_delim(buffer: &[u8]) -> Option<usize> {
368    (0..buffer.len().saturating_sub(1)).find(|&i| buffer[i] == b'\n' && buffer[i + 1] == b'\n')
369}