Skip to main content

everruns_mcp/
http.rs

1//! HTTP (Streamable-HTTP) MCP transport with multi-era protocol support.
2//!
3//! Lifted from `worker/src/mcp_executor.rs::call_mcp_tool` and
4//! `server/.../service.rs::fetch_mcp_tools` so the DNS-pinned SSRF contract
5//! (TM-TOOL-018) and SSE/JSON handling are shared, not duplicated
6//! (specs/runtime-mcp.md D1/D5). The free functions let callers that only hold
7//! a `&dyn EgressService` (e.g. the control plane) reuse the exact same path.
8//!
9//! The transport speaks all three MCP eras through one code path
10//! (specs/mcp-servers.md "Multi-era protocol support"): it emits `_meta` and
11//! routable headers on every request (additive for older servers), and for
12//! `Auto`/stateful servers it runs the `initialize` handshake and echoes the
13//! `Mcp-Session-Id`. See [`crate::protocol`] for the pure pieces.
14
15use 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
32/// Timeout for tool discovery (`tools/list`). Kept shorter than execution so a
33/// slow server doesn't stall turn-context assembly.
34const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
35/// Timeout for tool execution (`tools/call`).
36const CALL_TIMEOUT: Duration = Duration::from_secs(60);
37/// How long a per-transport negotiation verdict stays valid before re-probing.
38const NEGOTIATION_TTL: Duration = Duration::from_secs(300);
39
40/// Raw MCP HTTP response: status, headers, and the (possibly SSE-framed) body
41/// text — without the 2xx check, so negotiation can inspect failures.
42pub struct RawMcpResponse {
43    pub status: u16,
44    pub headers: BTreeMap<String, String>,
45    pub body: String,
46}
47
48/// Send one MCP JSON-RPC request and return the raw response (no status check).
49/// Performs DNS-pinned SSRF validation (TM-TOOL-018). `extra_headers` carry
50/// per-request protocol headers (routable headers, session id) on top of the
51/// server's configured `headers`.
52async 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
103/// Send one MCP JSON-RPC request over HTTP and return the (possibly SSE-framed)
104/// response body, erroring on non-2xx. Stateless one-shot — kept for callers
105/// that drive their own request shape. Performs DNS-pinned SSRF validation.
106pub 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
125/// Run the `initialize` handshake for a stateful (legacy/current) server and
126/// return the negotiated protocol (server-reported version + captured session
127/// id). Also fires a best-effort `notifications/initialized`.
128async 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    // The initialize result may be plain JSON or SSE-framed, like any MCP
147    // response — extract the JSON payload before reading the negotiated version.
148    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    // Best-effort `notifications/initialized` — some servers require it; ignore
154    // failures since the handshake itself already succeeded.
155    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, &note_extra, credential, note, timeout).await;
162    }
163
164    Ok(Negotiated {
165        version,
166        stateful: true,
167        session_id,
168    })
169}
170
171/// Send an already-built operation body under a negotiated protocol, attaching
172/// routable headers and the session id when stateful.
173#[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/// Drive one operation through protocol negotiation, returning the response
193/// body text and the negotiated protocol used (so a caller can cache it).
194///
195/// `Auto` starts stateless (RC) and, on a failure that signals the server needs
196/// a session, transparently runs the handshake and retries — this is what makes
197/// legacy, current, and RC servers all work without operator action.
198#[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    // A stateful negotiation with no session id yet must handshake first. This
214    // covers pinned Legacy/Stable and a cached stateful verdict that lost its
215    // session.
216    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    // Auto fallback: a stateless attempt the server rejected because it wants a
242    // session — handshake, then retry once.
243    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
315/// Parse a `tools/list` response body into tool definitions.
316fn 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
333/// Parse a `tools/call` response body into a tool-call result.
334fn 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
350/// Discover a server's tools via `tools/list`. Negotiates the protocol era
351/// (`Auto`); one-shot with no persistent cache (the control-plane caller layers
352/// its own tool cache on top).
353pub 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
376/// Execute a tool via `tools/call`. Negotiates the protocol era (`Auto`).
377pub 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/// MCP transport over the platform [`EgressService`] boundary.
403///
404/// Holds a small negotiation cache scoped by URL plus logical connection and
405/// auth context so a `tools/list` followed by a `tools/call` (the common turn
406/// pattern) reuses its own negotiated era and session id without crossing
407/// credentials or pinned protocol modes.
408#[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    /// Build a transport backed by a [`DirectEgressService`] (real outbound
467    /// HTTP). Used by hosts without a custom egress boundary, e.g. the CLI.
468    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    /// Fresh cached negotiation for one logical connection/auth scope, or
484    /// `None` if absent/expired.
485    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}