greentic-mcp-exec 1.1.2

Greentic executor for running wasix:mcp-compatible WebAssembly components with policy-driven verification and sandboxing.
Documentation
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;

use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use greentic_interfaces_wasmtime::host_helpers::v1::{runner_host_http, runner_host_kv};
use greentic_mcp_exec::router;
use greentic_mcp_exec::runner::{StoreState, add_secrets_to_linker};
use wasmtime::component::{Component, Linker};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::p2::add_to_linker_sync as add_wasi_to_linker;
use wasmtime_wasi_http::p2::add_only_http_to_linker_sync as add_wasi_http_to_linker;
use wasmtime_wasi_tls::p2::LinkOptions;

#[derive(Parser)]
#[command(
    name = "greentic-mcp-exec",
    version,
    about = "Execute wasix:mcp/router components locally"
)]
struct Cli {
    /// Increase diagnostic output.
    #[arg(short, long, global = true)]
    verbose: bool,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Invoke a router component export (wasix:mcp/router@25.6.18).
    Router(RouterCommand),
}

#[derive(Parser)]
struct RouterCommand {
    /// Path to the router component (.wasm).
    #[arg(long, value_name = "PATH")]
    router: PathBuf,
    /// Router tool name (alias: --operation).
    #[arg(long, alias = "operation", value_name = "NAME")]
    tool: Option<String>,
    /// List tools instead of calling one.
    #[arg(long)]
    list_tools: bool,
    /// Allow router HTTP calls (default off).
    #[arg(long)]
    enable_http: bool,
    /// Optional timeout in milliseconds for the router call/list.
    #[arg(long, value_name = "MILLIS")]
    timeout_ms: Option<u64>,
    /// Inline JSON arguments to pass to call-tool.
    #[arg(long, value_name = "JSON")]
    input: Option<String>,
    /// Read JSON arguments from file.
    #[arg(long, value_name = "FILE")]
    input_file: Option<PathBuf>,
    /// Pretty-print the response.
    #[arg(long)]
    pretty: bool,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Commands::Router(cmd) => run_router(cmd, cli.verbose),
    }
}

fn run_router(cmd: RouterCommand, verbose: bool) -> Result<()> {
    if verbose {
        eprintln!(
            "router CLI starting (list_tools={}, enable_http={})",
            cmd.list_tools, cmd.enable_http
        );
    }
    // Avoid blocking on stdin when we're only listing tools.
    let args_json = if cmd.list_tools {
        "{}".to_string()
    } else {
        load_input(cmd.input.clone(), cmd.input_file.clone())?
    };
    if verbose {
        eprintln!("creating wasmtime engine");
    }
    let engine = build_engine()?;
    if verbose {
        eprintln!("loading component {}", cmd.router.display());
    }
    let component = Component::from_file(&engine, &cmd.router)
        .map_err(|err| anyhow!("loading component {}: {}", cmd.router.display(), err))?;
    if verbose {
        eprintln!("component loaded");
    }

    // Offload instantiation/invocation to a worker so we can enforce a wallclock timeout.
    let timeout = cmd.timeout_ms.map(std::time::Duration::from_millis);
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let res = invoke_router(cmd, args_json, engine, component, verbose);
        let _ = tx.send(res);
    });

    match timeout {
        Some(dur) => match rx.recv_timeout(dur) {
            Ok(res) => res,
            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
                Err(anyhow!("router call timed out after {:?}", dur))
            }
            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
                Err(anyhow!("router call worker failed"))
            }
        },
        None => rx
            .recv()
            .map_err(|_| anyhow!("router call worker failed"))?,
    }
}

fn invoke_router(
    cmd: RouterCommand,
    args_json: String,
    engine: Engine,
    component: Component,
    verbose: bool,
) -> Result<()> {
    if verbose {
        eprintln!("creating linker and wiring wasi/hosts");
    }
    let mut linker = Linker::new(&engine);
    linker.allow_shadowing(true);
    add_wasi_to_linker(&mut linker)
        .map_err(|err| anyhow!("linking wasi preview2 imports: {}", err))?;

    // Mirror runtime linker setup so router components importing wasi:http/types
    // and wasi:tls types can instantiate in this direct CLI path.
    let mut opts = LinkOptions::default();
    opts.tls(true);
    wasmtime_wasi_tls::p2::add_to_linker(&mut linker, &opts)
        .map_err(|err| anyhow!("linking wasi tls imports: {}", err))?;
    add_wasi_http_to_linker(&mut linker)
        .map_err(|err| anyhow!("linking wasi http imports: {}", err))?;

    runner_host_http::add_runner_host_http_to_linker(&mut linker, |state: &mut StoreState| state)
        .map_err(|err| anyhow!("linking runner host http: {}", err))?;
    runner_host_kv::add_runner_host_kv_to_linker(&mut linker, |state: &mut StoreState| state)
        .map_err(|err| anyhow!("linking runner host kv: {}", err))?;
    add_secrets_to_linker(&mut linker).map_err(|err| anyhow!("linking secrets host: {}", err))?;
    add_generator_secrets_store_to_linker(&mut linker)
        .map_err(|err| anyhow!("linking greentic:secrets-store: {}", err))?;

    let http_enabled = cmd.enable_http && !cmd.list_tools;
    if verbose {
        eprintln!("building store (http_enabled={})", http_enabled);
    }
    let mut store = Store::new(&engine, StoreState::new(http_enabled, None, None));

    if verbose {
        eprintln!("instantiating router component {}", cmd.router.display());
    }
    let router = router::McpRouter::instantiate(&mut store, &component, &linker)
        .map_err(|err| anyhow!("component missing wasix:mcp/router@25.6.18 exports: {err}"))?;

    if verbose {
        let tool = cmd.tool.as_deref().unwrap_or("<list-tools>");
        eprintln!(
            "executing router `{}` via tool `{}`",
            cmd.router.display(),
            tool
        );
    }

    let router_iface = router.wasix_mcp_router();

    if cmd.list_tools {
        if verbose {
            eprintln!("calling list-tools");
        }
        let tools = router_iface
            .call_list_tools(&mut store)
            .map_err(|err| anyhow!(err.to_string()))?;
        if verbose {
            eprintln!("list-tools returned {} entries", tools.len());
        }
        let names: Vec<_> = tools.into_iter().map(|t| t.name).collect();
        if cmd.pretty {
            println!("{}", serde_json::to_string_pretty(&names)?);
        } else {
            println!("{}", serde_json::to_string(&names)?);
        }
        return Ok(());
    }

    let tool = cmd
        .tool
        .as_deref()
        .ok_or_else(|| anyhow!("--tool/--operation is required unless --list-tools is set"))?;

    let result = router_iface
        .call_call_tool(&mut store, tool, &args_json)
        .map_err(|err| anyhow!(err.to_string()))?;

    let json = match result {
        Ok(resp) => router::render_response(&resp),
        Err(err) => router::tool_error_to_value(tool, err),
    };

    if cmd.pretty {
        println!("{}", serde_json::to_string_pretty(&json)?);
    } else {
        println!("{}", serde_json::to_string(&json)?);
    }

    Ok(())
}

fn load_input(inline: Option<String>, file: Option<PathBuf>) -> Result<String> {
    if let Some(path) = file {
        let contents =
            fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
        return Ok(contents);
    }

    if let Some(inline) = inline {
        return Ok(inline);
    }

    let mut buf = String::new();
    io::stdin()
        .read_to_string(&mut buf)
        .context("reading stdin")?;
    if buf.trim().is_empty() {
        return Err(anyhow!(
            "no input provided (use --input, --input-file, or stdin)"
        ));
    }
    Ok(buf)
}

/// Component-typed mirror of the `secrets-error` enum used by router
/// components generated by greentic-mcp-generator.
#[derive(
    Copy,
    Clone,
    Debug,
    wasmtime::component::ComponentType,
    wasmtime::component::Lift,
    wasmtime::component::Lower,
)]
#[component(enum)]
#[repr(u8)]
#[allow(dead_code)] // variants mirror the WIT enum ABI even when only NotFound is constructed
enum GeneratorSecretsError {
    #[component(name = "not-found")]
    NotFound,
    #[component(name = "denied")]
    Denied,
    #[component(name = "invalid-key")]
    InvalidKey,
    #[component(name = "internal")]
    Internal,
}

/// Shim for `greentic:secrets-store/secrets-store@1.0.0` used by router
/// components generated by greentic-mcp-generator.
///
/// The generator builds secret keys like `auth.param.<operation>.<param>`. We
/// resolve them dynamically from the environment using the same convention the
/// generated router uses internally: `MCP_SECRET_<UPPERCASED_KEY>` where any
/// non-alphanumeric character becomes `_`. As a single-value fallback for
/// smoke tests, `GREENTIC_MCP_SECRET` is returned when neither the per-key var
/// nor the literal-key var is set.
fn add_generator_secrets_store_to_linker(linker: &mut Linker<StoreState>) -> wasmtime::Result<()> {
    let mut instance = linker.instance("greentic:secrets-store/secrets-store@1.0.0")?;
    instance.func_wrap(
        "get",
        |_caller: wasmtime::StoreContextMut<'_, StoreState>, (key,): (String,)| {
            let value = std::env::var(format!("MCP_SECRET_{}", env_key(&key)))
                .or_else(|_| std::env::var(&key))
                .or_else(|_| std::env::var("GREENTIC_MCP_SECRET"));
            let result: Result<Option<Vec<u8>>, GeneratorSecretsError> = match value {
                Ok(v) => Ok(Some(v.into_bytes())),
                Err(_) => Err(GeneratorSecretsError::NotFound),
            };
            Ok((result,))
        },
    )?;
    Ok(())
}

fn env_key(key: &str) -> String {
    key.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_uppercase()
            } else {
                '_'
            }
        })
        .collect()
}

fn build_engine() -> Result<Engine> {
    let mut config = Config::new();
    config.wasm_component_model(true);
    // Epoch interruption is disabled here; caller-driven timeouts are enforced by a worker thread.
    config.epoch_interruption(false);
    Engine::new(&config).map_err(|err| anyhow!("initializing wasmtime engine: {}", err))
}