everruns-core 0.43.0

Transport-neutral agent execution contracts for Everruns
Documentation
//! High-level MCP client: resolves credentials via a [`McpAuthProvider`],
//! selects the transport for a connection's endpoint, and maps results.

use crate::mcp::auth::{McpAuthProvider, McpAuthRequest, McpCredential};
use crate::mcp::elicitation::UrlElicitationHandler;
use crate::mcp::form_elicitation::FormElicitationHandler;
use crate::mcp::http::HttpTransport;
use crate::mcp::result::map_tool_call_result;
use crate::mcp::transport::{McpConnection, McpEndpoint, McpTransport};
use crate::{EgressService, McpToolCallResult, McpToolDefinition};
use anyhow::Result;
use everruns_contracts::ToolResultImage;
use everruns_contracts::tool_types::ToolResult;
use serde_json::Value;
use std::sync::Arc;

/// Transport-selecting MCP client shared by all hosts.
pub struct McpClient {
    http: Arc<HttpTransport>,
    #[cfg(feature = "mcp-stdio")]
    stdio: Arc<crate::mcp::stdio::StdioTransport>,
    auth: Arc<dyn McpAuthProvider>,
}

impl McpClient {
    /// Build a client over a custom egress boundary with the given auth
    /// provider.
    pub fn new(egress: Arc<dyn EgressService>, auth: Arc<dyn McpAuthProvider>) -> Self {
        Self::with_http(Arc::new(HttpTransport::new(egress)), auth)
    }

    /// Build a client whose HTTP transport can put URL mode elicitations in
    /// front of a human via `elicitation`. Hosts with no reachable user (an
    /// unattended worker run) use [`McpClient::new`] instead, so the client
    /// declares no elicitation capability and servers must not ask.
    pub fn with_url_elicitation(
        egress: Arc<dyn EgressService>,
        auth: Arc<dyn McpAuthProvider>,
        elicitation: Arc<dyn UrlElicitationHandler>,
    ) -> Self {
        Self::with_http(
            Arc::new(HttpTransport::new(egress).with_elicitation_handler(elicitation)),
            auth,
        )
    }

    /// Build a client that also answers form mode elicitations through
    /// `form`, for servers whose `elicitation_policy` opts in. Form mode is
    /// never declared to any other server.
    pub fn with_url_and_form_elicitation(
        egress: Arc<dyn EgressService>,
        auth: Arc<dyn McpAuthProvider>,
        url: Arc<dyn UrlElicitationHandler>,
        form: Arc<dyn FormElicitationHandler>,
    ) -> Self {
        Self::with_http(
            Arc::new(
                HttpTransport::new(egress)
                    .with_elicitation_handler(url)
                    .with_form_elicitation_handler(form),
            ),
            auth,
        )
    }

    pub fn with_http(http: Arc<HttpTransport>, auth: Arc<dyn McpAuthProvider>) -> Self {
        Self {
            http,
            #[cfg(feature = "mcp-stdio")]
            stdio: Arc::new(crate::mcp::stdio::StdioTransport),
            auth,
        }
    }

    fn transport_for(&self, connection: &McpConnection) -> &dyn McpTransport {
        match &connection.endpoint {
            McpEndpoint::Http { .. } => self.http.as_ref(),
            #[cfg(feature = "mcp-stdio")]
            McpEndpoint::Stdio { .. } => self.stdio.as_ref(),
        }
    }

    async fn credential(&self, connection: &McpConnection) -> Result<Option<McpCredential>> {
        self.auth
            .authorization(&McpAuthRequest {
                server_name: &connection.name,
                auth_mode: connection.auth_mode.clone(),
                oauth_provider_id: connection.oauth_provider_id.as_deref(),
            })
            .await
    }

    /// Discover the tools a server exposes via `tools/list`.
    pub async fn discover(&self, connection: &McpConnection) -> Result<Vec<McpToolDefinition>> {
        let credential = self.credential(connection).await?;
        self.transport_for(connection)
            .list_tools(connection, credential.as_ref())
            .await
    }

    /// Execute a tool via `tools/call`, returning the raw MCP result.
    pub async fn call(
        &self,
        connection: &McpConnection,
        tool_name: &str,
        arguments: Value,
    ) -> Result<McpToolCallResult> {
        let credential = self.credential(connection).await?;
        self.transport_for(connection)
            .call_tool(connection, tool_name, arguments, credential.as_ref())
            .await
    }

    /// Execute a tool and map the result into the internal `ToolResult` shape.
    pub async fn call_as_tool_result(
        &self,
        connection: &McpConnection,
        tool_call_id: impl Into<String>,
        tool_name: &str,
        arguments: Value,
    ) -> Result<ToolResult> {
        let result = self.call(connection, tool_name, arguments).await?;
        let (json_result, images) = map_tool_call_result(&result);
        Ok(tool_result(tool_call_id.into(), json_result, images))
    }
}

pub(crate) fn tool_result(
    tool_call_id: String,
    result: Value,
    images: Vec<ToolResultImage>,
) -> ToolResult {
    ToolResult {
        tool_call_id,
        result: Some(result),
        images: if images.is_empty() {
            None
        } else {
            Some(images)
        },
        error: None,
        connection_required: None,
        raw_output: None,
    }
}