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 {
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Router(RouterCommand),
}
#[derive(Parser)]
struct RouterCommand {
#[arg(long, value_name = "PATH")]
router: PathBuf,
#[arg(long, alias = "operation", value_name = "NAME")]
tool: Option<String>,
#[arg(long)]
list_tools: bool,
#[arg(long)]
enable_http: bool,
#[arg(long, value_name = "MILLIS")]
timeout_ms: Option<u64>,
#[arg(long, value_name = "JSON")]
input: Option<String>,
#[arg(long, value_name = "FILE")]
input_file: Option<PathBuf>,
#[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
);
}
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");
}
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))?;
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)
}
#[derive(
Copy,
Clone,
Debug,
wasmtime::component::ComponentType,
wasmtime::component::Lift,
wasmtime::component::Lower,
)]
#[component(enum)]
#[repr(u8)]
#[allow(dead_code)] enum GeneratorSecretsError {
#[component(name = "not-found")]
NotFound,
#[component(name = "denied")]
Denied,
#[component(name = "invalid-key")]
InvalidKey,
#[component(name = "internal")]
Internal,
}
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);
config.epoch_interruption(false);
Engine::new(&config).map_err(|err| anyhow!("initializing wasmtime engine: {}", err))
}