use std::collections::HashMap;
use std::path::PathBuf;
use greentic_mcp_exec::{ExecConfig, ExecRequest, RuntimePolicy, ToolStore, VerifyPolicy, exec};
#[cfg(greentic_mcp_local_wasm)]
use greentic_mcp_exec::{ToolDef, list_tools};
use serde_json::{Value, json};
pub fn cache_dir() -> PathBuf {
if let Ok(dir) = std::env::var("GREENTIC_MCP_LOCAL_CACHE_DIR") {
return PathBuf::from(dir);
}
if let Ok(root) = std::env::var("GREENTIC_EXTENSIONS_DIR") {
return PathBuf::from(root).join("mcp-local");
}
PathBuf::from(".mcp-local")
}
pub(crate) fn exec_config_for(component_ref: &str) -> ExecConfig {
let pinned_digest = read_pinned_digest(component_ref);
let security = if let Some(wasm_digest) = pinned_digest {
let mut required_digests = HashMap::new();
required_digests.insert(component_ref.to_string(), wasm_digest);
VerifyPolicy {
allow_unverified: false,
required_digests,
trusted_signers: Vec::new(),
}
} else {
VerifyPolicy {
allow_unverified: true,
required_digests: HashMap::new(),
trusted_signers: Vec::new(),
}
};
ExecConfig {
store: ToolStore::LocalDir(cache_dir()),
security,
runtime: RuntimePolicy::default(),
http_enabled: true,
secrets_store: None,
}
}
fn read_pinned_digest(component_ref: &str) -> Option<String> {
let wasm_dest = cache_dir().join(format!("{component_ref}.wasm"));
let sidecar = crate::mcp_store_pull::sidecar_path(&wasm_dest);
match std::fs::read_to_string(&sidecar) {
Ok(content) => {
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!(
component = %component_ref,
sidecar = %sidecar.display(),
error = %e,
"unexpected error reading wasm digest sidecar; falling back to allow_unverified"
);
None
}
}
}
#[cfg(greentic_mcp_local_wasm)]
pub async fn local_list_tools(component_ref: &str) -> Vec<ToolDef> {
let component = component_ref.to_string();
let config = exec_config_for(component_ref);
let res =
tokio::task::spawn_blocking(move || list_tools(&component, &config).map_err(Box::new))
.await;
match res {
Ok(Ok(tools)) => tools,
Ok(Err(e)) => {
tracing::warn!(
component = %component_ref,
error = %e,
"local mcp list_tools failed; skipping"
);
Vec::new()
}
Err(e) => {
tracing::warn!(
component = %component_ref,
error = %e,
"local mcp list_tools task panicked; skipping"
);
Vec::new()
}
}
}
pub async fn local_call_tool(component_ref: &str, tool: &str, args: &Value) -> Value {
let component = component_ref.to_string();
let action = tool.to_string();
let cloned_args = args.clone();
let config = exec_config_for(component_ref);
let req = ExecRequest {
component,
action,
args: cloned_args,
tenant: None,
};
let res = tokio::task::spawn_blocking(move || exec(req, &config).map_err(Box::new)).await;
match res {
Ok(Ok(value)) => value,
Ok(Err(e)) => json!({ "error": format!("local mcp call failed: {e}") }),
Err(e) => json!({ "error": format!("local mcp call panicked: {e}") }),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, unsafe_code)]
mod tests {
use super::*;
fn fixture_wasm() -> Option<std::path::PathBuf> {
let p = std::env::var("GREENTIC_MCP_ROUTER_ECHO_WASM")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../greentic-mcp/target/wasm32-wasip2/release/router_echo.wasm")
});
p.exists().then_some(p)
}
#[tokio::test]
#[serial_test::serial]
async fn local_call_tool_runs_in_process() {
let Some(src) = fixture_wasm() else {
return;
};
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", dir.path()) };
std::fs::copy(&src, dir.path().join("router_echo.wasm")).unwrap();
let out =
local_call_tool("router_echo", "echo", &serde_json::json!({"message": "hi"})).await;
assert!(!out.to_string().contains("\"error\""), "got: {out}");
}
#[tokio::test]
#[serial_test::serial]
async fn local_call_tool_missing_component_returns_error_value() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", dir.path()) };
let out = local_call_tool("nope", "echo", &serde_json::json!({})).await;
assert!(out.to_string().contains("error"), "got: {out}");
}
#[test]
#[serial_test::serial]
fn cache_dir_uses_extensions_dir_fallback() {
unsafe {
std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR");
std::env::set_var("GREENTIC_EXTENSIONS_DIR", "/tmp/ext");
}
assert_eq!(cache_dir(), PathBuf::from("/tmp/ext/mcp-local"));
unsafe { std::env::remove_var("GREENTIC_EXTENSIONS_DIR") };
}
#[test]
#[serial_test::serial]
fn cache_dir_defaults_to_dot_mcp_local() {
unsafe {
std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR");
std::env::remove_var("GREENTIC_EXTENSIONS_DIR");
}
assert_eq!(cache_dir(), PathBuf::from(".mcp-local"));
}
#[test]
#[serial_test::serial]
fn exec_config_verified_when_sidecar_present() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", dir.path()) };
let sidecar = dir.path().join("mycomp.wasm.sha256");
std::fs::write(&sidecar, "abcdef1234567890").unwrap();
let config = exec_config_for("mycomp");
assert!(
!config.security.allow_unverified,
"sidecar present → allow_unverified must be false"
);
assert_eq!(
config.security.required_digests.get("mycomp"),
Some(&"abcdef1234567890".to_string()),
);
unsafe { std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR") };
}
#[test]
#[serial_test::serial]
fn exec_config_unverified_without_sidecar() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", dir.path()) };
let config = exec_config_for("nosidecar");
assert!(
config.security.allow_unverified,
"no sidecar → allow_unverified must be true"
);
assert!(config.security.required_digests.is_empty());
unsafe { std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR") };
}
}