1use crate::auth::McpCredential;
16use crate::protocol::{self, Negotiated};
17use crate::result::extract_json_from_response;
18use crate::transport::{McpConnection, McpEndpoint, McpTransport};
19use anyhow::{Result, anyhow};
20use async_trait::async_trait;
21use everruns_core::{
22 DirectEgressService, EgressRequest, EgressRequestKind, EgressService, McpProtocolMode,
23 McpToolCallResponse, McpToolCallResult, McpToolDefinition, McpToolsListResponse,
24 normalize_mcp_error_code, validate_url_dns_pinned,
25};
26use serde_json::Value;
27use std::collections::{BTreeMap, HashMap, hash_map::DefaultHasher};
28use std::hash::{Hash, Hasher};
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, Instant};
31
32const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
35const CALL_TIMEOUT: Duration = Duration::from_secs(60);
37const NEGOTIATION_TTL: Duration = Duration::from_secs(300);
39
40pub struct RawMcpResponse {
43 pub status: u16,
44 pub headers: BTreeMap<String, String>,
45 pub body: String,
46}
47
48async fn send_raw(
53 egress: &dyn EgressService,
54 url: &str,
55 headers: &HashMap<String, String>,
56 extra_headers: &[(String, String)],
57 credential: Option<&McpCredential>,
58 body: Vec<u8>,
59 timeout: Duration,
60) -> Result<RawMcpResponse> {
61 let (validated_url, resolved_addrs) = validate_url_dns_pinned(url).await.map_err(|e| {
62 tracing::warn!(url = %url, error = %e, "Blocked MCP request: URL failed SSRF validation");
63 anyhow!("MCP server URL blocked: {}", e)
64 })?;
65 let pin_host = validated_url.host_str().unwrap_or("").to_string();
66
67 let mut request = EgressRequest::new("POST", url, EgressRequestKind::Mcp)
68 .pinned_addrs(pin_host, resolved_addrs)
69 .header("Content-Type", "application/json")
70 .header("Accept", "application/json, text/event-stream")
71 .timeout_ms(timeout.as_millis() as u64)
72 .body(body);
73
74 for (name, value) in headers {
75 request = request.header(name, value);
76 }
77 for (name, value) in extra_headers {
78 request = request.header(name, value);
79 }
80 if let Some(credential) = credential {
81 if let Some(auth) = &credential.authorization {
82 request = request.header("Authorization", auth.clone());
83 }
84 for (name, value) in &credential.headers {
85 request = request.header(name, value.clone());
86 }
87 }
88
89 let response = egress
90 .send(request)
91 .await
92 .map_err(|e| anyhow!("Failed to call MCP server: {}", e))?;
93
94 let body = String::from_utf8(response.body)
95 .map_err(|e| anyhow!("Failed to read MCP response body: {}", e))?;
96 Ok(RawMcpResponse {
97 status: response.status,
98 headers: response.headers,
99 body,
100 })
101}
102
103pub async fn http_send_rpc(
107 egress: &dyn EgressService,
108 url: &str,
109 headers: &HashMap<String, String>,
110 credential: Option<&McpCredential>,
111 body: Vec<u8>,
112 timeout: Duration,
113) -> Result<String> {
114 let response = send_raw(egress, url, headers, &[], credential, body, timeout).await?;
115 if !(200..300).contains(&response.status) {
116 return Err(anyhow!(
117 "MCP server returned error: {} - {}",
118 response.status,
119 response.body
120 ));
121 }
122 Ok(response.body)
123}
124
125async fn do_handshake(
129 egress: &dyn EgressService,
130 url: &str,
131 headers: &HashMap<String, String>,
132 credential: Option<&McpCredential>,
133 preferred_version: &str,
134 timeout: Duration,
135) -> Result<Negotiated> {
136 let body = serde_json::to_vec(&protocol::initialize_body(0, preferred_version))?;
137 let extra = protocol::routable_headers(preferred_version, "initialize", None);
138 let response = send_raw(egress, url, headers, &extra, credential, body, timeout).await?;
139 if !(200..300).contains(&response.status) {
140 return Err(anyhow!(
141 "MCP initialize handshake failed: {} - {}",
142 response.status,
143 response.body
144 ));
145 }
146 let init_json = extract_json_from_response(&response.body).unwrap_or(&response.body);
149 let version = protocol::protocol_version_from_initialize(init_json)
150 .unwrap_or_else(|| preferred_version.to_string());
151 let session_id = protocol::session_id_from_headers(&response.headers);
152
153 if let Ok(note) = serde_json::to_vec(&protocol::initialized_notification()) {
156 let mut note_extra =
157 protocol::routable_headers(&version, "notifications/initialized", None);
158 if let Some(session_id) = &session_id {
159 note_extra.push((protocol::HEADER_SESSION_ID.to_string(), session_id.clone()));
160 }
161 let _ = send_raw(egress, url, headers, ¬e_extra, credential, note, timeout).await;
162 }
163
164 Ok(Negotiated {
165 version,
166 stateful: true,
167 session_id,
168 })
169}
170
171#[allow(clippy::too_many_arguments)]
174async fn send_op(
175 egress: &dyn EgressService,
176 url: &str,
177 headers: &HashMap<String, String>,
178 credential: Option<&McpCredential>,
179 negotiated: &Negotiated,
180 method: &str,
181 tool_name: Option<&str>,
182 body: Vec<u8>,
183 timeout: Duration,
184) -> Result<RawMcpResponse> {
185 let mut extra = protocol::routable_headers(&negotiated.version, method, tool_name);
186 if let Some(session_id) = &negotiated.session_id {
187 extra.push((protocol::HEADER_SESSION_ID.to_string(), session_id.clone()));
188 }
189 send_raw(egress, url, headers, &extra, credential, body, timeout).await
190}
191
192#[allow(clippy::too_many_arguments)]
199async fn negotiate_and_send(
200 egress: &dyn EgressService,
201 url: &str,
202 headers: &HashMap<String, String>,
203 credential: Option<&McpCredential>,
204 mode: McpProtocolMode,
205 method: &str,
206 tool_name: Option<&str>,
207 op_body: Vec<u8>,
208 cached: Option<Negotiated>,
209 timeout: Duration,
210) -> Result<(String, Negotiated)> {
211 let mut negotiated = cached.unwrap_or_else(|| Negotiated::initial_for_mode(mode));
212
213 if negotiated.stateful && negotiated.session_id.is_none() {
217 negotiated = do_handshake(
218 egress,
219 url,
220 headers,
221 credential,
222 &negotiated.version,
223 timeout,
224 )
225 .await?;
226 }
227
228 let response = send_op(
229 egress,
230 url,
231 headers,
232 credential,
233 &negotiated,
234 method,
235 tool_name,
236 op_body.clone(),
237 timeout,
238 )
239 .await?;
240
241 let mut rejected_probe = None;
244 let response = if mode == McpProtocolMode::Auto
245 && !negotiated.stateful
246 && !(200..300).contains(&response.status)
247 && protocol::looks_like_handshake_required(response.status, &response.body)
248 {
249 tracing::debug!(
250 url = %url,
251 "MCP stateless attempt rejected; falling back to stateful handshake"
252 );
253 rejected_probe = Some((response.status, response.body.clone()));
254 negotiated = do_handshake(
255 egress,
256 url,
257 headers,
258 credential,
259 protocol::DEFAULT_STATEFUL_VERSION,
260 timeout,
261 )
262 .await
263 .map_err(|fallback_error| {
264 anyhow!(
265 "MCP RC probe failed: {} - {}; stable fallback failed: {}",
266 response.status,
267 response.body,
268 fallback_error
269 )
270 })?;
271 send_op(
272 egress,
273 url,
274 headers,
275 credential,
276 &negotiated,
277 method,
278 tool_name,
279 op_body,
280 timeout,
281 )
282 .await
283 .map_err(|fallback_error| {
284 anyhow!(
285 "MCP RC probe failed: {} - {}; stable fallback request failed: {}",
286 response.status,
287 response.body,
288 fallback_error
289 )
290 })?
291 } else {
292 response
293 };
294
295 if !(200..300).contains(&response.status) {
296 if let Some((probe_status, probe_body)) = rejected_probe {
297 return Err(anyhow!(
298 "MCP RC probe failed: {} - {}; stable fallback failed: {} - {}",
299 probe_status,
300 probe_body,
301 response.status,
302 response.body
303 ));
304 }
305 return Err(anyhow!(
306 "MCP server returned error: {} - {}",
307 response.status,
308 response.body
309 ));
310 }
311
312 Ok((response.body, negotiated))
313}
314
315fn parse_tools_list(text: &str) -> Result<Vec<McpToolDefinition>> {
317 let json_str = extract_json_from_response(text)
318 .ok_or_else(|| anyhow!("SSE response missing data line"))?;
319 let response: McpToolsListResponse = serde_json::from_str(json_str)?;
320 if let Some(error) = response.error {
321 return Err(anyhow!(
322 "MCP server error: {} ({})",
323 error.message,
324 normalize_mcp_error_code(error.code)
325 ));
326 }
327 Ok(response
328 .result
329 .ok_or_else(|| anyhow!("MCP server returned empty result"))?
330 .tools)
331}
332
333fn parse_tool_call(text: &str) -> Result<McpToolCallResult> {
335 let json_str = extract_json_from_response(text)
336 .ok_or_else(|| anyhow!("SSE response missing data line"))?;
337 let response: McpToolCallResponse = serde_json::from_str(json_str)?;
338 if let Some(error) = response.error {
339 return Err(anyhow!(
340 "MCP tool error: {} (code: {})",
341 error.message,
342 normalize_mcp_error_code(error.code)
343 ));
344 }
345 response
346 .result
347 .ok_or_else(|| anyhow!("MCP server returned empty result"))
348}
349
350pub async fn http_list_tools(
354 egress: &dyn EgressService,
355 url: &str,
356 headers: &HashMap<String, String>,
357 credential: Option<&McpCredential>,
358) -> Result<Vec<McpToolDefinition>> {
359 let body = serde_json::to_vec(&protocol::tools_list_body(1))?;
360 let (text, _negotiated) = negotiate_and_send(
361 egress,
362 url,
363 headers,
364 credential,
365 McpProtocolMode::Auto,
366 "tools/list",
367 None,
368 body,
369 None,
370 DISCOVERY_TIMEOUT,
371 )
372 .await?;
373 parse_tools_list(&text)
374}
375
376pub async fn http_call_tool(
378 egress: &dyn EgressService,
379 url: &str,
380 headers: &HashMap<String, String>,
381 tool_name: &str,
382 arguments: Value,
383 credential: Option<&McpCredential>,
384) -> Result<McpToolCallResult> {
385 let body = serde_json::to_vec(&protocol::tools_call_body(1, tool_name, &arguments))?;
386 let (text, _negotiated) = negotiate_and_send(
387 egress,
388 url,
389 headers,
390 credential,
391 McpProtocolMode::Auto,
392 "tools/call",
393 Some(tool_name),
394 body,
395 None,
396 CALL_TIMEOUT,
397 )
398 .await?;
399 parse_tool_call(&text)
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Hash)]
409struct NegotiationCacheKey {
410 url: String,
411 server_name: String,
412 protocol_mode: String,
413 headers_hash: u64,
414 credential_hash: u64,
415}
416
417impl NegotiationCacheKey {
418 fn new(
419 connection: &McpConnection,
420 url: &str,
421 headers: &HashMap<String, String>,
422 credential: Option<&McpCredential>,
423 ) -> Self {
424 Self {
425 url: url.to_string(),
426 server_name: connection.name.clone(),
427 protocol_mode: connection.protocol_mode.to_string(),
428 headers_hash: hash_headers(headers),
429 credential_hash: hash_credential(credential),
430 }
431 }
432}
433
434fn hash_headers(headers: &HashMap<String, String>) -> u64 {
435 let mut hasher = DefaultHasher::new();
436 let sorted: BTreeMap<_, _> = headers.iter().collect();
437 sorted.hash(&mut hasher);
438 hasher.finish()
439}
440
441fn hash_credential(credential: Option<&McpCredential>) -> u64 {
442 let mut hasher = DefaultHasher::new();
443 credential
444 .and_then(|credential| credential.authorization.as_deref())
445 .hash(&mut hasher);
446 if let Some(credential) = credential {
447 let sorted: BTreeMap<_, _> = credential.headers.iter().collect();
448 sorted.hash(&mut hasher);
449 }
450 hasher.finish()
451}
452
453pub struct HttpTransport {
454 egress: Arc<dyn EgressService>,
455 negotiations: Mutex<HashMap<NegotiationCacheKey, (Negotiated, Instant)>>,
456}
457
458impl HttpTransport {
459 pub fn new(egress: Arc<dyn EgressService>) -> Self {
460 Self {
461 egress,
462 negotiations: Mutex::new(HashMap::new()),
463 }
464 }
465
466 pub fn direct() -> Self {
469 Self::new(Arc::new(DirectEgressService::default()))
470 }
471
472 fn http_parts(connection: &McpConnection) -> Result<(&str, &HashMap<String, String>)> {
473 match &connection.endpoint {
474 McpEndpoint::Http { url, headers } => Ok((url.as_str(), headers)),
475 #[cfg(feature = "stdio")]
476 _ => Err(anyhow!(
477 "HttpTransport received a non-HTTP endpoint for server '{}'",
478 connection.name
479 )),
480 }
481 }
482
483 fn cached_negotiation(&self, key: &NegotiationCacheKey) -> Option<Negotiated> {
486 let cache = self.negotiations.lock().ok()?;
487 let (negotiated, at) = cache.get(key)?;
488 (at.elapsed() < NEGOTIATION_TTL).then(|| negotiated.clone())
489 }
490
491 fn store_negotiation(&self, key: NegotiationCacheKey, negotiated: Negotiated) {
492 if let Ok(mut cache) = self.negotiations.lock() {
493 cache.insert(key, (negotiated, Instant::now()));
494 }
495 }
496}
497
498#[async_trait]
499impl McpTransport for HttpTransport {
500 async fn list_tools(
501 &self,
502 connection: &McpConnection,
503 credential: Option<&McpCredential>,
504 ) -> Result<Vec<McpToolDefinition>> {
505 let (url, headers) = Self::http_parts(connection)?;
506 let cache_key = NegotiationCacheKey::new(connection, url, headers, credential);
507 let cached = self.cached_negotiation(&cache_key);
508 let body = serde_json::to_vec(&protocol::tools_list_body(1))?;
509 let (text, negotiated) = negotiate_and_send(
510 self.egress.as_ref(),
511 url,
512 headers,
513 credential,
514 connection.protocol_mode,
515 "tools/list",
516 None,
517 body,
518 cached,
519 DISCOVERY_TIMEOUT,
520 )
521 .await?;
522 self.store_negotiation(cache_key, negotiated);
523 parse_tools_list(&text)
524 }
525
526 async fn call_tool(
527 &self,
528 connection: &McpConnection,
529 tool_name: &str,
530 arguments: Value,
531 credential: Option<&McpCredential>,
532 ) -> Result<McpToolCallResult> {
533 let (url, headers) = Self::http_parts(connection)?;
534 let cache_key = NegotiationCacheKey::new(connection, url, headers, credential);
535 let cached = self.cached_negotiation(&cache_key);
536 let body = serde_json::to_vec(&protocol::tools_call_body(1, tool_name, &arguments))?;
537 let (text, negotiated) = negotiate_and_send(
538 self.egress.as_ref(),
539 url,
540 headers,
541 credential,
542 connection.protocol_mode,
543 "tools/call",
544 Some(tool_name),
545 body,
546 cached,
547 CALL_TIMEOUT,
548 )
549 .await?;
550 self.store_negotiation(cache_key, negotiated);
551 parse_tool_call(&text)
552 }
553}