oxicode_agent/mcp/transport/
http.rs1use 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
36pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
39
40pub struct StreamableHttpTransport {
42 endpoint: String,
43 server_name: String,
44 client: Client,
45 session_id: Mutex<Option<String>>,
48 inbound_handler: parking_lot::Mutex<Option<InboundHandler>>,
53 #[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 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 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 #[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 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 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 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 if status.is_success() {
181 self.capture_session_id(&resp).await;
182 }
183
184 if (status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN)
186 && let Some(provider) = self.credential_provider.as_ref()
187 {
188 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 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 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 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
335fn 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 }
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}