runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use std::time::Duration;

use runx_contracts::{JsonObject, JsonValue, sha256_hex};

use crate::RuntimeError;
use crate::adapter::{InvocationStatus, SkillAdapter, SkillInvocation, SkillOutput};
use crate::adapter_pipeline::{AdapterCapture, AdapterExecutionContext, AdapterInvocationPlan};
use crate::credentials::CredentialDelivery;
use crate::sandbox::sandbox_metadata;
use crate::services::SandboxServices;

use super::sandbox_metadata::mcp_process_sandbox_metadata;
use super::templates::map_mcp_arguments;
use super::transport::ProcessMcpTransport;
use super::types::{McpToolCallRequest, McpTransport};

const DEFAULT_TIMEOUT_MS: u64 = 60_000;
const MIN_TIMEOUT_MS: u64 = 50;

#[derive(Clone, Debug)]
pub struct McpAdapter<T = ProcessMcpTransport> {
    transport: T,
}

impl<T> McpAdapter<T> {
    #[must_use]
    pub const fn new(transport: T) -> Self {
        Self { transport }
    }
}

impl Default for McpAdapter<ProcessMcpTransport> {
    fn default() -> Self {
        Self::new(ProcessMcpTransport::default())
    }
}

impl<T> SkillAdapter for McpAdapter<T>
where
    T: McpTransport,
{
    fn adapter_type(&self) -> &'static str {
        "mcp"
    }

    fn invoke(&self, request: SkillInvocation) -> Result<SkillOutput, RuntimeError> {
        let context = AdapterExecutionContext::start(AdapterInvocationPlan::from_invocation(
            self.adapter_type(),
            &request,
        ));
        let prepared = match prepare_mcp_tool_call(request, &context)? {
            Ok(prepared) => prepared,
            Err(output) => return Ok(output),
        };
        match self.transport.call_tool(prepared.request) {
            Ok(result) => Ok(context.projection().output(
                InvocationStatus::Success,
                AdapterCapture::new(
                    prepared
                        .credential_delivery
                        .redact_text(super::templates::stringify_mcp_tool_result(&result)?),
                    String::new(),
                ),
                Some(0),
                prepared.success_metadata,
            )),
            Err(error) => Ok(failure(
                prepared
                    .credential_delivery
                    .redact_text(error.sanitized_message()),
                &context,
                prepared.failure_metadata,
            )),
        }
    }
}

#[derive(Debug)]
struct PreparedMcpToolCall {
    request: McpToolCallRequest,
    credential_delivery: CredentialDelivery,
    success_metadata: JsonObject,
    failure_metadata: JsonObject,
}

fn prepare_mcp_tool_call(
    invocation: SkillInvocation,
    context: &AdapterExecutionContext,
) -> Result<Result<PreparedMcpToolCall, SkillOutput>, RuntimeError> {
    let SkillInvocation {
        source,
        inputs,
        resolved_inputs,
        skill_directory,
        env,
        credential_delivery,
        ..
    } = invocation;
    if source.source_type != runx_parser::SourceKind::Mcp {
        return Err(RuntimeError::UnsupportedAdapter {
            adapter_type: source.source_type.as_str().to_owned(),
        });
    }
    let Some(server) = source.server.clone() else {
        return Ok(Err(missing_mcp_metadata(context)));
    };
    let Some(tool) = source.tool.clone().filter(|tool| !tool.is_empty()) else {
        return Ok(Err(missing_mcp_metadata(context)));
    };
    let arguments = map_mcp_arguments(source.arguments.as_ref(), &inputs, &resolved_inputs)?;
    let sandbox = match SandboxServices.mcp_process_plan(&source, &server, &skill_directory, &env) {
        Ok(plan) => plan,
        Err(RuntimeError::SandboxViolation { message }) => {
            return Ok(Err(failure(
                format!("MCP sandbox denied: {message}"),
                context,
                metadata_for(&source, Some(sandbox_metadata(source.sandbox.as_ref())))?,
            )));
        }
        Err(error) => return Err(error),
    };
    let success_metadata = metadata_for(
        &source,
        Some(mcp_process_sandbox_metadata(
            source.sandbox.as_ref(),
            &sandbox,
            &env,
        )?),
    )?;
    let failure_metadata = metadata_for(&source, None)?;
    Ok(Ok(PreparedMcpToolCall {
        request: McpToolCallRequest {
            server,
            tool,
            arguments,
            timeout: timeout_from_source(source.timeout_seconds),
            sandbox,
            secret_env: credential_delivery.secret_env().clone(),
        },
        credential_delivery,
        success_metadata,
        failure_metadata,
    }))
}

fn missing_mcp_metadata(context: &AdapterExecutionContext) -> SkillOutput {
    failure(
        "MCP source requires server and tool metadata.",
        context,
        JsonObject::new(),
    )
}

fn metadata_for(
    source: &runx_parser::SkillSource,
    sandbox: Option<JsonObject>,
) -> Result<JsonObject, RuntimeError> {
    let mut mcp = JsonObject::new();
    mcp.insert(
        "tool".to_owned(),
        JsonValue::String(source.tool.clone().unwrap_or_default()),
    );
    let server = source.server.as_ref();
    mcp.insert(
        "server_command_hash".to_owned(),
        JsonValue::String(sha256_hex(
            server
                .map(|server| server.command.as_bytes())
                .unwrap_or(b""),
        )),
    );
    let args = serde_json::to_string(&server.map(|server| &server.args))
        .map_err(|source| RuntimeError::json("serializing MCP server args", source))?;
    mcp.insert(
        "server_args_hash".to_owned(),
        JsonValue::String(sha256_hex(args.as_bytes())),
    );

    let mut metadata = JsonObject::new();
    metadata.insert("mcp".to_owned(), JsonValue::Object(mcp));
    if let Some(sandbox) = sandbox.filter(|sandbox| !sandbox.is_empty()) {
        metadata.insert("sandbox".to_owned(), JsonValue::Object(sandbox));
    }
    Ok(metadata)
}

pub(super) fn failure(
    message: impl Into<String>,
    context: &AdapterExecutionContext,
    metadata: JsonObject,
) -> SkillOutput {
    context.projection().failure(message.into(), metadata)
}

fn timeout_from_source(timeout_seconds: Option<u64>) -> Duration {
    let timeout_ms = timeout_seconds
        .map(|seconds| seconds.saturating_mul(1000))
        .unwrap_or(DEFAULT_TIMEOUT_MS)
        .max(MIN_TIMEOUT_MS);
    Duration::from_millis(timeout_ms)
}