mcp-exec 0.4.8

Greentic executor for running wasix:mcp-compatible WebAssembly components with policy-driven verification and sandboxing.
Documentation
use anyhow::{Context, Result};
use greentic_types::{SecretFormat, SecretKey, SecretRequirement, SecretScope};
use serde_json::Value;
use tracing::warn;

use crate::{ExecConfig, ExecError, ExecRequest, exec};

#[cfg(feature = "describe-v1")]
const DESCRIBE_INTERFACE: &str = "greentic:component/describe-v1@1.0.0";
#[cfg(feature = "describe-v1")]
const DESCRIBE_EXPORT: &str = "greentic:component/describe-v1@1.0.0#describe-json";

#[derive(Debug)]
pub enum Maybe<T> {
    Data(T),
    Unsupported,
}

#[derive(Debug)]
pub struct ToolDescribe {
    pub describe_v1: Option<Value>,
    pub capabilities: Maybe<Vec<String>>,
    pub secrets: Maybe<Value>,
    pub config_schema: Maybe<Value>,
    pub secret_requirements: Vec<SecretRequirement>,
}

pub fn describe_tool(name: &str, cfg: &ExecConfig) -> Result<ToolDescribe> {
    #[cfg(feature = "describe-v1")]
    {
        if let Some(document) = try_describe_v1(name, cfg)? {
            let (secret_requirements, used_legacy) =
                secret_requirements(Some(&document), &Maybe::Unsupported);
            if used_legacy {
                warn!(
                    tool = name,
                    "legacy secrets descriptors were mapped; emit `secret_requirements` in describe-json"
                );
            }
            return Ok(ToolDescribe {
                describe_v1: Some(document),
                capabilities: Maybe::Unsupported,
                secrets: Maybe::Unsupported,
                config_schema: Maybe::Unsupported,
                secret_requirements,
            });
        }
    }

    fn try_action(name: &str, action: &str, cfg: &ExecConfig) -> Result<Maybe<Value>> {
        let req = ExecRequest {
            component: name.to_string(),
            action: action.to_string(),
            args: Value::Object(Default::default()),
            tenant: None,
        };

        match exec(req, cfg) {
            Ok(v) => Ok(Maybe::Data(v)),
            Err(ExecError::NotFound { .. }) => Ok(Maybe::Unsupported),
            Err(ExecError::Tool { code, payload, .. }) if code == "iface-error.not-found" => {
                let _ = payload;
                Ok(Maybe::Unsupported)
            }
            Err(e) => Err(e.into()),
        }
    }

    let capabilities_value = try_action(name, "capabilities", cfg)?;
    let secrets = try_action(name, "list_secrets", cfg)?;
    let config_schema = try_action(name, "config_schema", cfg)?;

    let capabilities = match capabilities_value {
        Maybe::Data(value) => {
            if let Some(arr) = value.as_array() {
                let list = arr
                    .iter()
                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
                    .collect::<Vec<_>>();
                Maybe::Data(list)
            } else {
                Maybe::Data(Vec::new())
            }
        }
        Maybe::Unsupported => Maybe::Unsupported,
    };

    let (secret_requirements, used_legacy) = secret_requirements(None, &secrets);
    if used_legacy {
        warn!(
            tool = name,
            "legacy secrets descriptors were mapped; emit `secret_requirements` in tool metadata"
        );
    }

    Ok(ToolDescribe {
        describe_v1: None,
        capabilities,
        secrets,
        config_schema,
        secret_requirements,
    })
}

#[cfg(feature = "describe-v1")]
fn try_describe_v1(name: &str, cfg: &ExecConfig) -> Result<Option<Value>> {
    use wasmtime::component::{Component, Linker};
    use wasmtime::{Config, Engine, Store};

    let resolved =
        crate::resolve::resolve(name, &cfg.store).map_err(|err| ExecError::resolve(name, err))?;
    let verified = crate::verify::verify(name, resolved, &cfg.security)
        .map_err(|err| ExecError::verification(name, err))?;

    let mut config = Config::new();
    config.wasm_component_model(true);
    config.async_support(false);
    config.epoch_interruption(true);

    let engine = Engine::new(&config)?;
    let component = match Component::from_binary(&engine, verified.resolved.bytes.as_ref()) {
        Ok(component) => component,
        Err(_) => return Ok(None),
    };
    let linker = Linker::new(&engine);
    let mut store = Store::new(&engine, ());

    let instance = match linker.instantiate(&mut store, &component) {
        Ok(instance) => instance,
        Err(_) => return Ok(None),
    };
    if instance
        .get_export(&mut store, None, DESCRIBE_INTERFACE)
        .is_none()
    {
        return Ok(None);
    }

    let func = match instance.get_typed_func::<(), (String,)>(&mut store, DESCRIBE_EXPORT) {
        Ok(func) => func,
        Err(err) => {
            let msg = err.to_string();
            if msg.contains("unknown export") {
                return Ok(None);
            }
            return Err(err);
        }
    };

    let (raw,) = func.call(&mut store, ())?;
    let value: Value =
        serde_json::from_str(&raw).with_context(|| "describe-json returned invalid JSON")?;
    Ok(Some(value))
}

pub const RUNTIME_SENTINEL: &str = "runtime";

fn secret_requirements(
    describe_v1: Option<&Value>,
    secrets: &Maybe<Value>,
) -> (Vec<SecretRequirement>, bool) {
    if let Some(requirements) = describe_v1.and_then(|doc| doc.get("secret_requirements")) {
        let parsed = normalize_requirements(requirements);
        return (dedup(parsed), false);
    }

    if let Maybe::Data(value) = secrets {
        let parsed = normalize_requirements(value);
        return (dedup(parsed), true);
    }

    (Vec::new(), matches!(secrets, Maybe::Data(_)))
}

fn normalize_requirements(value: &Value) -> Vec<SecretRequirement> {
    if let Some(arr) = value.as_array() {
        return arr.iter().filter_map(parse_requirement).collect();
    }

    if let Some(obj) = value.as_object() {
        if let Some(reqs) = obj.get("secret_requirements").and_then(Value::as_array) {
            return reqs.iter().filter_map(parse_requirement).collect();
        }
        if let Some(reqs) = obj.get("secrets").and_then(Value::as_array) {
            return reqs.iter().filter_map(parse_requirement).collect();
        }
    }

    Vec::new()
}

fn parse_requirement(value: &Value) -> Option<SecretRequirement> {
    match value {
        Value::String(key) => {
            let key = SecretKey::new(key).ok()?;
            let mut req = SecretRequirement::default();
            req.key = key;
            req.required = true;
            req.scope = Some(runtime_scope());
            req.format = Some(default_format());
            Some(req)
        }
        Value::Object(obj) => {
            let key_raw = obj
                .get("key")
                .or_else(|| obj.get("name"))
                .and_then(Value::as_str)?;
            let key = SecretKey::new(key_raw).ok()?;
            let required = obj
                .get("required")
                .and_then(Value::as_bool)
                .unwrap_or_else(|| {
                    !obj.get("optional")
                        .and_then(Value::as_bool)
                        .unwrap_or(false)
                });

            let scope = parse_scope(obj.get("scope"))
                .or_else(|| parse_scope(Some(&Value::Object(obj.clone()))))
                .unwrap_or_else(runtime_scope);

            let format = obj
                .get("format")
                .and_then(Value::as_str)
                .and_then(parse_format)
                .unwrap_or_else(default_format);

            let description = obj
                .get("description")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned);

            let schema = obj.get("schema").cloned();
            let examples = obj
                .get("examples")
                .and_then(Value::as_array)
                .map(|arr| {
                    arr.iter()
                        .filter_map(example_to_string)
                        .collect::<Vec<String>>()
                })
                .unwrap_or_default();

            let mut req = SecretRequirement::default();
            req.key = key;
            req.required = required;
            req.description = description;
            req.scope = Some(scope);
            req.format = Some(format);
            req.schema = schema;
            req.examples = examples;
            Some(req)
        }
        _ => None,
    }
}

fn parse_scope(value: Option<&Value>) -> Option<SecretScope> {
    let obj = value?.as_object()?;
    let env = obj.get("env").and_then(Value::as_str)?;
    let tenant = obj.get("tenant").and_then(Value::as_str)?;
    let team = obj
        .get("team")
        .and_then(Value::as_str)
        .map(ToOwned::to_owned);

    Some(SecretScope {
        env: env.to_owned(),
        tenant: tenant.to_owned(),
        team,
    })
}

fn parse_format(value: &str) -> Option<SecretFormat> {
    match value.trim().to_ascii_lowercase().as_str() {
        "json" => Some(SecretFormat::Json),
        "text" => Some(SecretFormat::Text),
        "opaque" => Some(SecretFormat::Bytes),
        "binary" | "bytes" | "byte" | "bin" => Some(SecretFormat::Bytes),
        _ => None,
    }
}

fn runtime_scope() -> SecretScope {
    SecretScope {
        env: RUNTIME_SENTINEL.to_owned(),
        tenant: RUNTIME_SENTINEL.to_owned(),
        team: None,
    }
}

fn default_format() -> SecretFormat {
    SecretFormat::Text
}

fn example_to_string(value: &Value) -> Option<String> {
    match value {
        Value::String(s) => Some(s.clone()),
        other => serde_json::to_string(other).ok(),
    }
}

fn dedup(requirements: Vec<SecretRequirement>) -> Vec<SecretRequirement> {
    use std::collections::HashSet;

    let mut seen = HashSet::new();
    let mut out = Vec::with_capacity(requirements.len());
    for req in requirements {
        let scope_key = req
            .scope
            .as_ref()
            .map(|scope| (scope.env.clone(), scope.tenant.clone(), scope.team.clone()));
        if seen.insert((req.key.clone(), scope_key)) {
            out.push(req);
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn maps_describe_v1_secret_requirements() {
        let describe_v1 = json!({
            "name": "demo",
            "secret_requirements": [
                {
                    "key": "api-key",
                    "required": false,
                    "format": "json",
                    "scope": { "env": "dev", "tenant": "acme" },
                    "description": "auth key"
                }
            ]
        });

        let (reqs, used_legacy) = secret_requirements(Some(&describe_v1), &Maybe::Unsupported);
        assert!(!used_legacy);
        assert_eq!(reqs.len(), 1);
        let req = &reqs[0];
        assert_eq!(req.key.as_str(), "api-key");
        assert!(!req.required);
        assert_eq!(req.format, Some(SecretFormat::Json));
        let scope = req.scope.as_ref().expect("scope set");
        assert_eq!(scope.env, "dev");
        assert_eq!(scope.tenant, "acme");
        assert_eq!(req.description.as_deref(), Some("auth key"));
    }

    #[test]
    fn maps_legacy_list_secrets_strings() {
        let secrets = Maybe::Data(json!(["token", "secondary"]));
        let (reqs, used_legacy) = secret_requirements(None, &secrets);
        assert!(used_legacy);
        assert_eq!(reqs.len(), 2);

        for req in reqs {
            assert!(req.required);
            assert_eq!(req.format, Some(SecretFormat::Text));
            let scope = req.scope.as_ref().expect("scope set");
            assert_eq!(scope.env, RUNTIME_SENTINEL);
            assert_eq!(scope.tenant, RUNTIME_SENTINEL);
        }
    }
}