diff --git a/Cargo.lock b/Cargo.lock index cf6ba7c30..87ec9b0d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,6 +1527,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -2412,6 +2434,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -4645,6 +4676,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -6429,6 +6466,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature 2.2.0", + "zeroize", +] + [[package]] name = "jwt" version = "0.16.0" @@ -8211,7 +8263,7 @@ dependencies = [ "http-auth", "jwt", "lazy_static 1.5.0", - "oci-spec", + "oci-spec 0.8.4", "olpc-cjson", "regex", "reqwest 0.12.28", @@ -8224,6 +8276,32 @@ dependencies = [ "unicase", ] +[[package]] +name = "oci-client" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b7f8deaffcd3b0e3baf93dddcab3d18b91d46dc37d38a8b170089b234de5bb3" +dependencies = [ + "bytes 1.11.1", + "chrono", + "futures-util", + "http", + "http-auth", + "jsonwebtoken", + "lazy_static 1.5.0", + "oci-spec 0.9.0", + "olpc-cjson", + "regex", + "reqwest 0.13.2", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio 1.50.0", + "tracing", + "unicase", +] + [[package]] name = "oci-spec" version = "0.8.4" @@ -8241,6 +8319,23 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "oci-spec" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8445a2631507cec628a15fdd6154b54a3ab3f20ed4fe9d73a3b8b7a4e1ba03a" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.18", +] + [[package]] name = "oci-wasm" version = "0.3.0" @@ -8249,7 +8344,7 @@ checksum = "1b0e073bbc223f0ea26fed8da329622d763ffd5fcd197dfdfb8818cbe8b7b7a5" dependencies = [ "anyhow", "chrono", - "oci-client", + "oci-client 0.15.0", "serde", "serde_json", "sha2 0.10.9", @@ -8258,6 +8353,23 @@ dependencies = [ "wit-parser 0.230.0", ] +[[package]] +name = "oci-wasm" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841cceed413ad8a4c8b4a833ccfa333eebe55dfb12af7c3899687258934ca2a4" +dependencies = [ + "anyhow", + "chrono", + "oci-client 0.16.1", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio 1.50.0", + "wit-component 0.244.0", + "wit-parser 0.244.0", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -9735,6 +9847,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes 1.11.1", "getrandom 0.3.4", "lru-slab", @@ -10171,15 +10284,25 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite 0.2.17", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio 1.50.0", + "tokio-native-tls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -10524,6 +10647,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -10555,6 +10679,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.102.8" @@ -10572,6 +10723,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -13242,6 +13394,8 @@ dependencies = [ "iri-string", "layer-climb", "layer-climb-cli", + "oci-client 0.16.1", + "oci-wasm 0.4.0", "opentelemetry", "opentelemetry-jaeger-propagator", "opentelemetry-otlp", @@ -13828,8 +13982,8 @@ dependencies = [ "docker_credential", "etcetera", "futures-util", - "oci-client", - "oci-wasm", + "oci-client 0.15.0", + "oci-wasm 0.3.0", "reqwest 0.12.28", "secrecy", "serde", @@ -14508,6 +14662,7 @@ dependencies = [ "wasmtime", "wavs-engine", "wavs-types", + "wit-schema", ] [[package]] @@ -14573,7 +14728,10 @@ version = "2.8.0" dependencies = [ "alloy-contract", "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", "alloy-signer", + "alloy-signer-local", "alloy-sol-macro", "alloy-sol-types", "anyhow", @@ -14589,7 +14747,9 @@ dependencies = [ "tracing", "tracing-subscriber", "utils", + "wasmtime", "wavs-types", + "wit-schema", ] [[package]] @@ -14726,6 +14886,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -15744,6 +15913,21 @@ dependencies = [ "wasmparser 0.245.1", ] +[[package]] +name = "wit-schema" +version = "2.8.0" +dependencies = [ + "anyhow", + "lru 0.16.3", + "serde_json", + "tempfile", + "tokio 1.50.0", + "tracing", + "wasmtime", + "wavs-types", + "wit-parser 0.244.0", +] + [[package]] name = "witx" version = "0.9.1" diff --git a/Cargo.toml b/Cargo.toml index 3fa1183c6..549e4f987 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "packages/utils", "packages/version-pins", "packages/wavs", + "packages/wit-schema", "examples/components/cosmos-query", "examples/components/chain-trigger-lookup", "examples/components/echo-data", @@ -167,6 +168,8 @@ cw2 = "3.0.0" # WASM and WIT bindings wasm-pkg-client = "0.12.0" wasm-pkg-common = "0.12.0" +oci-client = "0.16" +oci-wasm = "0.4" wasmtime = { version = "42.0.1", features = [ "cache", "component-model", @@ -177,6 +180,7 @@ wasmtime-wasi = { version = "42.0.1", default-features = true } wasmtime-wasi-http = "42.0.1" wasmtime-wasi-tls = "42.0.1" wit-bindgen = "0.53.1" +wit-parser = "0.244.0" wavs-wasi-utils = { path = "packages/wasi-utils" } wasip2 = "1.0.1" wstd = "0.6.5" @@ -291,3 +295,4 @@ example-types = { path = "examples/components/_types" } cw-wavs-mock-api = { path = "examples/contracts/cosmwasm/mock/api" } cw-wavs-trigger-api = { path = "examples/contracts/cosmwasm/trigger/api" } wavs-gui-shared = { path = "packages/gui/shared" } +wit-schema = { path = "packages/wit-schema" } diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index bd5186a00..895343db2 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -36,3 +36,4 @@ cron = { workspace = true } rand = { workspace = true } opentelemetry = { workspace = true } iri-string = { workspace = true } +wit-schema = { workspace = true } diff --git a/packages/cli/src/args.rs b/packages/cli/src/args.rs index 1d7145179..1405b0a75 100644 --- a/packages/cli/src/args.rs +++ b/packages/cli/src/args.rs @@ -123,6 +123,20 @@ pub enum Command { args: CliArgs, }, + /// Generate JSON Schema from a compiled WASM component's WIT interface + WitSchema { + /// Path to the compiled WASI component (.wasm file) + #[clap(long)] + component: String, + + /// Optional path to WIT source directory for doc comment enrichment + #[clap(long)] + wit_path: Option, + + #[clap(flatten)] + args: CliArgs, + }, + /// Execute aggregator components directly ExecAggregator { #[clap(flatten)] @@ -419,6 +433,7 @@ impl Command { Self::UploadComponent { args, .. } => args, Self::Exec { args, .. } => args, Self::Service { args, .. } => args, + Self::WitSchema { args, .. } => args, Self::ExecAggregator { args, .. } => args, }; diff --git a/packages/cli/src/command/mod.rs b/packages/cli/src/command/mod.rs index 6a32148aa..a7ce39d42 100644 --- a/packages/cli/src/command/mod.rs +++ b/packages/cli/src/command/mod.rs @@ -3,3 +3,4 @@ pub mod exec_aggregator; pub mod exec_component; pub mod service; pub mod upload_component; +pub mod wit_schema; diff --git a/packages/cli/src/command/wit_schema.rs b/packages/cli/src/command/wit_schema.rs new file mode 100644 index 000000000..466c33cd3 --- /dev/null +++ b/packages/cli/src/command/wit_schema.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use wasmtime::{component::Component, Config as WTConfig, Engine as WTEngine}; +use wit_schema::{generate_schema, SchemaOptions}; + +use crate::util::read_component; + +pub struct WitSchemaArgs { + pub component_path: String, + pub wit_path: Option, +} + +pub fn run(args: WitSchemaArgs) -> Result { + let wasm_bytes = read_component(&args.component_path).context(format!( + "Failed to read WASM component from path: {}", + args.component_path + ))?; + + let mut config = WTConfig::new(); + config.wasm_component_model(true); + let engine = WTEngine::new(&config) + .map_err(|e| anyhow::anyhow!("Failed to create Wasmtime engine: {e}"))?; + + let component = Component::new(&engine, &wasm_bytes).map_err(|e| { + anyhow::anyhow!( + "Failed to load WASM component. Is this a valid component (not a core module)? {e}" + ) + })?; + + let options = SchemaOptions { + wit_path: args.wit_path, + }; + + generate_schema(&engine, &component, &options) + .context("Failed to generate schema from component") +} diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index af940f792..da41979f9 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -21,6 +21,7 @@ use wavs_cli::{ exec_component::{ExecComponent, ExecComponentArgs}, service::handle_service_command, upload_component::{UploadComponent, UploadComponentArgs}, + wit_schema, }, context::CliContext, util::{write_output_file, ComponentInput}, @@ -135,11 +136,36 @@ async fn main() { .try_init() .unwrap(); + // Handle commands that don't need CliContext (purely local, no network) + if let Command::WitSchema { + component, + wit_path, + args: _, + } = &command + { + match wit_schema::run(wit_schema::WitSchemaArgs { + component_path: component.clone(), + wit_path: wit_path.clone(), + }) { + Ok(schema) => { + // D-08: Always output JSON to stdout, pipe-friendly + println!("{}", serde_json::to_string_pretty(&schema).unwrap()); + return; + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + } + + // Only create CliContext for commands that need it let ctx = CliContext::try_new(&command, config.clone(), None) .await .unwrap(); match command { + Command::WitSchema { .. } => unreachable!("handled above"), Command::DeployService { service_uri, set_uri, diff --git a/packages/utils/Cargo.toml b/packages/utils/Cargo.toml index f5c58a52c..f59b855bd 100644 --- a/packages/utils/Cargo.toml +++ b/packages/utils/Cargo.toml @@ -15,6 +15,8 @@ test-utils = ["dep:rand", "dep:bip39", "dep:toml", "dep:cw-wavs-mock-api"] [dependencies] wasm-pkg-client = { workspace = true } wavs-types = { workspace = true, features = ["full"] } +oci-client = { workspace = true } +oci-wasm = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } tracing = { workspace = true } diff --git a/packages/utils/src/context.rs b/packages/utils/src/context.rs index 6aa6a22e7..1d793fe57 100644 --- a/packages/utils/src/context.rs +++ b/packages/utils/src/context.rs @@ -26,6 +26,13 @@ impl AnyRuntime { } } + pub fn enter(&self) -> tokio::runtime::EnterGuard<'_> { + match self { + AnyRuntime::Tokio(rt) => rt.enter(), + AnyRuntime::TokioHandle(handle) => handle.enter(), + } + } + pub fn spawn(&self, fut: F) -> tokio::task::JoinHandle where F: std::future::Future + Send + 'static, diff --git a/packages/utils/src/lib.rs b/packages/utils/src/lib.rs index 789e68299..81584a464 100644 --- a/packages/utils/src/lib.rs +++ b/packages/utils/src/lib.rs @@ -9,6 +9,7 @@ pub mod evm_client; pub mod filesystem; pub mod health; pub mod http; +pub mod oci; pub mod serde; pub mod service; pub mod storage; diff --git a/packages/utils/src/oci.rs b/packages/utils/src/oci.rs new file mode 100644 index 000000000..3096732cc --- /dev/null +++ b/packages/utils/src/oci.rs @@ -0,0 +1,201 @@ +//! OCI registry client for pulling WASM components. +//! +//! Pulls WASM components from OCI-compliant registries (ghcr.io, Docker Hub, private registries) +//! using the `oci://` URI scheme. Components are returned as raw bytes for downstream +//! digest verification and content-addressed storage. + +use anyhow::{anyhow, Result}; +use oci_client::{client::ClientConfig, secrets::RegistryAuth, Client as OciClient, Reference}; +use oci_wasm::WasmClient; + +/// Parsed OCI URI components. +/// +/// Splits an `oci://registry/repo:tag@sha256:digest` URI into an +/// `oci_client::Reference` (for the pull) and an optional digest string +/// (for WAVS-level content verification). +#[derive(Debug, Clone)] +pub struct OciUri { + /// The OCI reference used by oci-client for the pull operation. + pub reference: Reference, + /// The `sha256:...` digest extracted from the URI's `@sha256:` suffix, if present. + /// This is the OCI *manifest* digest, not the WASM content digest. + /// When present, it ensures the registry returns the exact manifest requested. + pub manifest_digest: Option, +} + +impl OciUri { + /// Parse an `oci://` prefixed URI into its components. + /// + /// Accepts: + /// - `oci://ghcr.io/org/component:tag` + /// - `oci://ghcr.io/org/component@sha256:abc123...` + /// - `oci://ghcr.io/org/component:tag@sha256:abc123...` + /// + /// Returns an error if the URI does not start with `oci://` or the reference + /// portion is not a valid OCI reference. + pub fn parse(uri: &str) -> Result { + let raw = uri + .strip_prefix("oci://") + .ok_or_else(|| anyhow!("OCI URI must start with oci://, got: {}", uri))?; + + // oci_client::Reference::from_str handles: + // ghcr.io/org/component:tag + // ghcr.io/org/component@sha256:abc123 + // ghcr.io/org/component:tag@sha256:abc123 + let reference: Reference = raw + .parse() + .map_err(|e| anyhow!("Invalid OCI reference '{}': {}", raw, e))?; + + let manifest_digest = reference.digest().map(|d| d.to_string()); + + Ok(OciUri { + reference, + manifest_digest, + }) + } + + /// Returns true if this URI has no `@sha256:` digest pin. + /// Tag-only references resolve to whatever the registry currently maps the tag to, + /// which may change over time. + pub fn is_unpinned(&self) -> bool { + self.manifest_digest.is_none() + } +} + +/// Pulls WASM components from OCI registries. +/// +/// Wraps `oci-wasm::WasmClient` which handles WASM-specific OCI media types +/// (`application/wasm`, `application/vnd.wasm.config.v0+json`). +/// +/// # Versioning note +/// This module uses `oci-client` 0.16 / `oci-wasm` 0.4 as direct dependencies. +/// The existing `wasm-pkg-client` depends on `oci-client` 0.15 transitively. +/// These are kept strictly separate -- this module exposes only `Vec` (raw bytes) +/// to avoid type conflicts between the two oci-client versions. +pub struct OciPuller { + client: WasmClient, +} + +impl OciPuller { + /// Create a new OCI puller with default client configuration. + pub fn new() -> Self { + let config = ClientConfig::default(); + let oci_client = OciClient::new(config); + Self { + client: WasmClient::new(oci_client), + } + } + + /// Pull a WASM component from an OCI registry. + /// + /// Returns the raw WASM bytes. The caller is responsible for digest + /// verification and storage. + /// + /// # Errors + /// - Registry is unreachable or returns an error + /// - The manifest contains no layer with WASM media type + /// - Authentication fails for private registries + pub async fn pull(&self, uri: &OciUri, auth: &RegistryAuth) -> Result> { + tracing::info!( + reference = %uri.reference, + pinned = !uri.is_unpinned(), + "Pulling WASM component from OCI registry" + ); + + let image_data = self + .client + .pull(&uri.reference, auth) + .await + .map_err(|e| anyhow!("OCI pull failed for {}: {}", uri.reference, e))?; + + // oci-wasm returns ImageData with layers filtered to WASM media types. + // The WASM binary is the first (and typically only) layer. + let wasm_layer = + image_data.layers.into_iter().next().ok_or_else(|| { + anyhow!("No WASM layer found in OCI manifest for {}", uri.reference) + })?; + + tracing::info!( + reference = %uri.reference, + size_bytes = wasm_layer.data.len(), + "OCI pull complete" + ); + + Ok(wasm_layer.data.to_vec()) + } + + /// Build `RegistryAuth` from environment variables. + /// + /// Reads `WAVS_OCI_USERNAME` and `WAVS_OCI_PASSWORD`. Both must be set + /// for Basic auth; otherwise falls back to Anonymous. + pub fn auth_from_env() -> RegistryAuth { + match ( + std::env::var("WAVS_OCI_USERNAME"), + std::env::var("WAVS_OCI_PASSWORD"), + ) { + (Ok(user), Ok(pass)) => { + tracing::debug!("Using OCI Basic auth from WAVS_OCI_USERNAME/WAVS_OCI_PASSWORD"); + RegistryAuth::Basic(user, pass) + } + _ => { + tracing::debug!("No OCI credentials found, using anonymous auth"); + RegistryAuth::Anonymous + } + } + } +} + +impl Default for OciPuller { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_oci_uri_with_tag() { + let uri = OciUri::parse("oci://ghcr.io/layerlabs/echo-data:v1.0").unwrap(); + assert!(uri.is_unpinned()); + assert!(uri.manifest_digest.is_none()); + // Reference should contain the tag + assert!(uri.reference.tag().is_some() || uri.reference.digest().is_none()); + } + + #[test] + fn parse_oci_uri_with_digest() { + let uri = OciUri::parse( + "oci://ghcr.io/layerlabs/echo-data@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" + ).unwrap(); + assert!(!uri.is_unpinned()); + assert!(uri.manifest_digest.is_some()); + assert!(uri.manifest_digest.unwrap().starts_with("sha256:")); + } + + #[test] + fn parse_oci_uri_rejects_non_oci_prefix() { + let result = OciUri::parse("https://ghcr.io/layerlabs/echo-data:v1.0"); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("oci://")); + } + + #[test] + fn parse_oci_uri_with_tag_and_digest() { + let uri = OciUri::parse( + "oci://ghcr.io/layerlabs/echo-data:v1.0@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" + ).unwrap(); + assert!(!uri.is_unpinned()); + assert!(uri.manifest_digest.is_some()); + } + + #[test] + fn auth_from_env_anonymous_when_no_vars() { + // This test relies on WAVS_OCI_USERNAME not being set in the test environment + // which is the default case + let auth = OciPuller::auth_from_env(); + assert!(matches!(auth, RegistryAuth::Anonymous)); + } +} diff --git a/packages/wavs-mcp/Cargo.toml b/packages/wavs-mcp/Cargo.toml index 6a07cf1d6..6c6899e77 100644 --- a/packages/wavs-mcp/Cargo.toml +++ b/packages/wavs-mcp/Cargo.toml @@ -15,14 +15,19 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } clap = { workspace = true } -wavs-types = { workspace = true } +wavs-types = { workspace = true, features = ["signer"] } +alloy-signer-local = { workspace = true } anyhow = { workspace = true } const-hex = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } utils = { path = "../utils" } +wit-schema = { path = "../wit-schema" } +wasmtime = { workspace = true, features = ["component-model"] } alloy-primitives = { workspace = true } alloy-signer = { workspace = true } +alloy-rpc-types-eth = { workspace = true } +alloy-provider = { workspace = true } alloy-sol-types = { workspace = true } alloy-sol-macro = { workspace = true } alloy-contract = { workspace = true } diff --git a/packages/wavs-mcp/src/client.rs b/packages/wavs-mcp/src/client.rs index 43beac9af..abd9aa3a2 100644 --- a/packages/wavs-mcp/src/client.rs +++ b/packages/wavs-mcp/src/client.rs @@ -40,6 +40,15 @@ impl WavsClient { parse_json_response(resp).await } + pub async fn get_chains(&self) -> Result { + let resp = self + .request(Method::GET, "/chains") + .send() + .await + .context("GET /chains")?; + parse_json_response(resp).await + } + pub async fn get_health(&self) -> Result { let resp = self .request(Method::GET, "/health") @@ -258,6 +267,37 @@ impl WavsClient { Err(_) => const_hex::encode(&bytes), }) } + + /// POST /dev/execute -- synchronously execute a component and return results. + /// + /// Calls the WAVS node's `/dev/execute` endpoint which bypasses the full + /// trigger/aggregator/submission pipeline and returns the raw component output. + pub async fn execute_component( + &self, + service_id: &str, + workflow_id: &str, + trigger_json: &serde_json::Value, + data_json: &serde_json::Value, + ) -> Result> { + let body = serde_json::json!({ + "service_id": service_id, + "workflow_id": workflow_id, + "trigger": trigger_json, + "data": data_json, + }); + let resp = self + .request(Method::POST, "/dev/execute") + .json(&body) + .send() + .await + .context("POST /dev/execute")?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(dev_err(status, &body)); + } + resp.json().await.context("parse execute response") + } } fn dev_err(status: reqwest::StatusCode, body: &str) -> anyhow::Error { diff --git a/packages/wavs-mcp/src/exec.rs b/packages/wavs-mcp/src/exec.rs new file mode 100644 index 000000000..de580e113 --- /dev/null +++ b/packages/wavs-mcp/src/exec.rs @@ -0,0 +1,1184 @@ +//! Execution tool pipeline: dynamic tool generation from deployed services, +//! Tier 1 (result_only) execution dispatch, types, error codes, schema merging, +//! service cache, ExecContext, PendingConfirmations, and tool name sanitization. +//! +//! This module provides the public API for wiring execution tools into the MCP +//! server: `build_exec_tools()` generates Tool definitions from the service list, +//! and `handle_exec_tool()` dispatches `wavs_exec_*` tool calls through the +//! WAVS node's `/dev/execute` endpoint. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; + +use alloy_provider::Provider; +use rmcp::model::{CallToolResult, Content, ErrorCode, Tool}; +use serde::Deserialize; +use tokio::sync::RwLock; +use utils::evm_client::signing::make_signer; +use utils::evm_client::{EvmEndpoint, EvmSigningClient, EvmSigningClientConfig}; +use wavs_types::{Credential, ServiceManager, SignatureKind, WavsSignable}; + +use crate::client::WavsClient; + +// ── Type alias ──────────────────────────────────────────────────────────── + +/// Re-use the MCP error type from rmcp. +pub type McpError = rmcp::model::ErrorData; + +// ── Trust tiers (D-05, D-06, D-07, EXEC-05) ────────────────────────────── + +/// Trust tier for execution tool calls. +/// +/// - `ResultOnly` — raw component output, no cryptographic wrapper. +/// - `SignedResult` — component output wrapped with operator signature. +/// - `OnChain` — component output submitted on-chain; returns tx hash. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TrustTier { + ResultOnly, + SignedResult, + OnChain, +} + +// ── Error code constants (D-13) ────────────────────────────────────────── + +pub const ERR_EXECUTION_TIMEOUT: &str = "EXECUTION_TIMEOUT"; +pub const ERR_TIER_NOT_ENABLED: &str = "TIER_NOT_ENABLED"; +pub const ERR_SERVICE_NOT_FOUND: &str = "SERVICE_NOT_FOUND"; +pub const ERR_COMPONENT_FAILED: &str = "COMPONENT_FAILED"; +pub const ERR_SIGNING_FAILED: &str = "SIGNING_FAILED"; +pub const ERR_SUBMISSION_FAILED: &str = "SUBMISSION_FAILED"; + +// ── Timeout constants (EXEC-08, D-14) ──────────────────────────────────── + +/// Maximum per-call timeout in milliseconds. +pub const MAX_TIMEOUT_MS: u64 = 25_000; + +/// Default per-call timeout in milliseconds. +pub const DEFAULT_TIMEOUT_MS: u64 = 25_000; + +// ── Structured error helper (D-13, D-15) ───────────────────────────────── + +/// Return a structured MCP error result with an error code, message, and +/// optional partial result (hex-encoded payload from a successful component +/// execution that failed at a later stage such as signing or submission). +pub fn exec_error( + code: &str, + message: &str, + partial_result: Option<&[u8]>, +) -> Result { + let mut error = serde_json::json!({ + "error_code": code, + "message": message, + }); + + // D-15: include raw result if component execution succeeded + if let Some(payload) = partial_result { + error["partial_result"] = serde_json::json!({ + "payload": const_hex::encode(payload), + }); + } + + Ok(CallToolResult { + content: vec![Content::text( + serde_json::to_string_pretty(&error).unwrap_or_else(|_| error.to_string()), + )], + is_error: Some(true), + }) +} + +/// Convenience wrapper: same as `exec_error` but returns the inner +/// `CallToolResult` directly (useful when building an `McpError::data` field). +fn exec_error_value(code: &str, message: &str, partial_result: Option<&[u8]>) -> McpError { + let mut error = serde_json::json!({ + "error_code": code, + "message": message, + }); + if let Some(payload) = partial_result { + error["partial_result"] = serde_json::json!({ + "payload": const_hex::encode(payload), + }); + } + McpError { + code: ErrorCode::INTERNAL_ERROR, + message: message.to_string().into(), + data: Some(error.into()), + } +} + +// ── RawPayload (signable wrapper for arbitrary bytes) ──────────────── + +/// Thin wrapper that makes arbitrary bytes signable via the `WavsSigner` +/// blanket implementation. +struct RawPayload(Vec); + +impl WavsSignable for RawPayload { + fn encode_data(&self) -> anyhow::Result> { + Ok(self.0.clone()) + } +} + +// ── Tool name sanitization (Pitfall 3) ─────────────────────────────────── + +/// Sanitize a free-form string into a valid MCP tool name fragment. +/// +/// Rules: lowercase, replace non-alphanumeric with `_`, collapse consecutive +/// underscores, trim leading/trailing `_`, truncate to 64 chars. +pub fn sanitize_tool_name(name: &str) -> String { + let mut result = String::with_capacity(name.len()); + let mut last_was_underscore = true; // prevents leading underscore + + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + result.push(ch.to_ascii_lowercase()); + last_was_underscore = false; + } else if !last_was_underscore { + result.push('_'); + last_was_underscore = true; + } + } + + // Trim trailing underscore + while result.ends_with('_') { + result.pop(); + } + + // Truncate to 64 chars (on a char boundary, though we only have ASCII) + result.truncate(64); + + // Trim trailing underscore again if truncation exposed one + while result.ends_with('_') { + result.pop(); + } + + result +} + +// ── Schema merging (EXEC-05, D-14, Pitfall 1) ─────────────────────────── + +/// Merge a WIT-derived `inputSchema` with execution meta-parameters +/// (`trust_tier`, `timeout_ms`, `confirm`) to produce the final MCP tool +/// `inputSchema`. +/// +/// The WIT params are nested under an `"input"` property to avoid name +/// collisions between component parameters and meta-parameters. +pub fn merge_exec_schema(wit_input_schema: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "input": wit_input_schema, + "trust_tier": { + "type": "string", + "enum": ["result_only", "signed_result", "on_chain"], + "description": "Trust level for this execution. result_only: raw component output. signed_result: output + operator signature. on_chain: submit result as blockchain transaction.", + "default": "result_only" + }, + "timeout_ms": { + "type": "integer", + "description": "Per-call timeout in milliseconds (max 25000).", + "default": DEFAULT_TIMEOUT_MS, + "maximum": MAX_TIMEOUT_MS + }, + "confirm": { + "type": "string", + "description": "For on_chain tier: pass the nonce from the gas estimate response to confirm and submit the transaction." + } + }, + "required": ["trust_tier"] + }) +} + +// ── Service cache (D-04, Pattern 3) ────────────────────────────────────── + +/// Thread-safe service list cache with a configurable TTL. +/// +/// The cached value is the raw JSON from `GET /services` on the WAVS node. +/// Both `list_tools()` (for dynamic tool generation) and `call_tool()` (for +/// service lookup) share the same cache instance. +pub struct ServiceCache { + inner: RwLock>, + ttl: Duration, +} + +struct CachedServices { + services: serde_json::Value, + fetched_at: Instant, +} + +impl ServiceCache { + /// Create a new cache with the given time-to-live. + pub fn new(ttl: Duration) -> Self { + Self { + inner: RwLock::new(None), + ttl, + } + } + + /// Return the cached service list if it exists and is not stale. + pub async fn get(&self) -> Option { + let guard = self.inner.read().await; + guard.as_ref().and_then(|cached| { + if cached.fetched_at.elapsed() < self.ttl { + Some(cached.services.clone()) + } else { + None + } + }) + } + + /// Store a fresh service list in the cache. + pub async fn set(&self, services: serde_json::Value) { + let mut guard = self.inner.write().await; + *guard = Some(CachedServices { + services, + fetched_at: Instant::now(), + }); + } + + /// Immediately invalidate the cache (e.g. after deploy/delete). + pub async fn invalidate(&self) { + let mut guard = self.inner.write().await; + *guard = None; + } +} + +// ── ExecContext ─────────────────────────────────────────────────────────── + +/// Extensible context passed to `handle_exec_tool()` so that the function +/// signature does not need to change when Plan 03 adds fields (e.g. +/// signing credentials, pending confirmations). +pub struct ExecContext<'a> { + /// HTTP client for the WAVS node. + pub client: &'a WavsClient, + /// Cached service list JSON from `GET /services`. + pub services_json: &'a serde_json::Value, + /// Available after Plan 03 adds signing support. + pub signing_mnemonic: Option<&'a wavs_types::Credential>, + /// Available after Plan 03 adds on-chain submission. + pub mcp_chain_credential: Option<&'a wavs_types::Credential>, + /// Shared pending confirmations cache for Tier 3 two-step flow. + pub pending_confirmations: Option<&'a PendingConfirmations>, +} + +// ── PendingConfirmations (D-09) ────────────────────────────────────────── + +/// A pending execution awaiting user confirmation for on-chain submission. +pub struct PendingExecution { + pub service_id: String, + pub workflow_id: String, + pub payload: Vec, + pub gas_estimate: String, + pub chain_id: String, + pub service_manager_address: String, + pub rpc_url: Option, + pub created_at: Instant, +} + +/// Thread-safe store for pending Tier 3 executions awaiting confirmation. +/// +/// Each entry is keyed by a hex nonce and auto-expires after 60 seconds. +pub struct PendingConfirmations { + inner: RwLock>, +} + +impl PendingConfirmations { + pub fn new() -> Self { + Self { + inner: RwLock::new(HashMap::new()), + } + } + + /// Store a pending execution and return the nonce the agent must send + /// back to confirm submission. + pub async fn store(&self, execution: PendingExecution) -> String { + let nonce = format!( + "{:016x}", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + ); + self.inner.write().await.insert(nonce.clone(), execution); + nonce + } + + /// Take (remove) a pending execution by nonce, garbage-collecting any + /// entries older than 60 seconds. + pub async fn take(&self, nonce: &str) -> Option { + let mut map = self.inner.write().await; + // Garbage-collect expired entries + map.retain(|_, v| v.created_at.elapsed() < Duration::from_secs(60)); + map.remove(nonce) + } +} + +impl Default for PendingConfirmations { + fn default() -> Self { + Self::new() + } +} + +// ── Dynamic tool generation (D-01, D-02, D-03, EXEC-01) ───────────────── + +/// Extract a human-readable component source description from a workflow JSON. +fn component_source_desc(workflow: &serde_json::Value) -> String { + let source = &workflow["component"]["source"]; + + if let Some(uri) = source["oci"]["uri"].as_str() { + return uri.to_string(); + } + if let Some(digest) = source["digest"].as_str() { + let short = if digest.len() > 12 { + &digest[..12] + } else { + digest + }; + return format!("component:{short}"); + } + if let Some(uri) = source["download"]["uri"].as_str() { + return uri.to_string(); + } + + "local".to_string() +} + +/// Build MCP Tool definitions for all deployed service workflows. +/// +/// Each service workflow gets one tool named `wavs_exec_{sanitized_service_name}_{workflow_id}`. +/// The `services_json` is the response from `GET /services` on the WAVS node -- +/// a JSON object where each key is a service identifier. +pub fn build_exec_tools(services_json: &serde_json::Value) -> Vec { + let mut tools = Vec::new(); + + let services = match services_json.as_object() { + Some(obj) => obj, + None => return tools, + }; + + for (_service_id, service) in services { + let service_name = service["name"].as_str().unwrap_or("unknown"); + let workflows = match service["workflows"].as_object() { + Some(w) => w, + None => continue, + }; + + for (workflow_id, workflow) in workflows { + let sanitized_name = sanitize_tool_name(service_name); + let tool_name = format!("wavs_exec_{sanitized_name}_{workflow_id}"); + + let source_desc = component_source_desc(workflow); + let description = format!( + "Execute {service_name} workflow '{workflow_id}'. Source: {source_desc}. \ + Supports trust tiers: result_only, signed_result, on_chain." + ); + + // Build a permissive input schema (generic object) since the MCP server + // does not have access to the component bytes for full WIT parsing. + let wit_schema = serde_json::json!({ + "type": "object", + "description": "Input data to pass to the component. Structure depends on the component's WIT interface.", + "additionalProperties": true + }); + let input_schema = merge_exec_schema(wit_schema); + + // Convert the merged schema Value to the Arc format rmcp expects. + let schema_map: Arc> = + Arc::new(input_schema.as_object().cloned().unwrap_or_default()); + + tools.push(Tool { + name: tool_name.into(), + description: description.into(), + input_schema: schema_map, + }); + } + } + + tools +} + +// ── Service resolution ─────────────────────────────────────────────────── + +/// Resolve a `wavs_exec_*` tool name back to the service and workflow it targets. +/// +/// Returns `(service_id_hex, workflow_id, service_name, component_source_desc)`. +fn resolve_tool_service( + tool_name: &str, + services_json: &serde_json::Value, +) -> Option<(String, String, String, String)> { + let suffix = tool_name.strip_prefix("wavs_exec_")?; + + let services = services_json.as_object()?; + + for (service_id, service) in services { + let service_name = service["name"].as_str().unwrap_or("unknown"); + let sanitized_name = sanitize_tool_name(service_name); + let workflows = service["workflows"].as_object()?; + + for (workflow_id, workflow) in workflows { + let expected = format!("{sanitized_name}_{workflow_id}"); + if suffix == expected { + return Some(( + service_id.clone(), + workflow_id.clone(), + service_name.to_string(), + component_source_desc(workflow), + )); + } + } + } + + None +} + +// ── Tier 1 execution dispatch (EXEC-02, EXEC-08, D-14) ────────────────── + +/// Handle a `wavs_exec_*` tool call. Extracts trust_tier, timeout, and input +/// from args, then executes the component via the WAVS node's `/dev/execute` +/// endpoint. +/// +/// This function handles Tier 1 (`result_only`) directly. Tier 2 and 3 return +/// placeholder errors until Plan 03 adds support. +pub async fn handle_exec_tool( + ctx: &ExecContext<'_>, + tool_name: &str, + args: Option>, +) -> Result { + let args_map = args.unwrap_or_default(); + + // 1. Parse trust_tier (required) + let trust_tier: TrustTier = match args_map.get("trust_tier") { + Some(v) => serde_json::from_value(v.clone()).map_err(|e| McpError { + code: ErrorCode::INVALID_PARAMS, + message: format!( + "Invalid trust_tier: {e}. Must be one of: result_only, signed_result, on_chain" + ) + .into(), + data: None, + })?, + None => { + return Err(McpError { + code: ErrorCode::INVALID_PARAMS, + message: "Missing required parameter: trust_tier".into(), + data: None, + }); + } + }; + + // 2. Parse timeout_ms (optional, default DEFAULT_TIMEOUT_MS, clamp to MAX_TIMEOUT_MS) + let timeout_ms: u64 = match args_map.get("timeout_ms") { + Some(v) => { + let raw = v.as_u64().unwrap_or(DEFAULT_TIMEOUT_MS); + raw.min(MAX_TIMEOUT_MS) + } + None => DEFAULT_TIMEOUT_MS, + }; + + // 3. Parse input (optional, defaults to empty object) + let input = args_map + .get("input") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + + // 4. Resolve service and workflow from tool name + let (service_id, workflow_id, service_name, _source_desc) = + resolve_tool_service(tool_name, ctx.services_json).ok_or_else(|| { + // Return as a tool result error, not an MCP protocol error + McpError { + code: ErrorCode::INVALID_PARAMS, + message: format!( + "No service found for tool '{tool_name}'. \ + The service may have been removed. Call tools/list to refresh." + ) + .into(), + data: None, + } + })?; + + // 5. Dispatch by trust tier + match trust_tier { + TrustTier::ResultOnly => { + // Build trigger and data JSON for the /dev/execute endpoint + let trigger = serde_json::json!({"manual": null}); + + // Serialize input to bytes for the Raw data variant + let input_bytes = serde_json::to_vec(&input).unwrap_or_default(); + let data = serde_json::json!({"Raw": input_bytes}); + + // Execute with timeout + let execute_fut = + ctx.client + .execute_component(&service_id, &workflow_id, &trigger, &data); + + let result = + match tokio::time::timeout(Duration::from_millis(timeout_ms), execute_fut).await { + Err(_elapsed) => { + return exec_error( + ERR_EXECUTION_TIMEOUT, + &format!("Component execution timed out after {timeout_ms}ms"), + None, + ); + } + Ok(Err(e)) => { + return exec_error( + ERR_COMPONENT_FAILED, + &format!( + "Component execution failed for {service_name}/{workflow_id}: {e:#}" + ), + None, + ); + } + Ok(Ok(responses)) => responses, + }; + + // Extract the first WasmResponse payload + if result.is_empty() { + return exec_error( + ERR_COMPONENT_FAILED, + "Component returned no responses", + None, + ); + } + + // The response is a Vec where each item has a "payload" field (hex bytes) + let first = &result[0]; + let payload_display = if let Some(payload) = first.get("payload") { + // payload is typically a hex string or array of bytes + if let Some(hex_str) = payload.as_str() { + // Try to decode hex to UTF-8 for display + match const_hex::decode(hex_str) { + Ok(bytes) => match String::from_utf8(bytes.clone()) { + Ok(text) => text, + Err(_) => format!("0x{hex_str}"), + }, + Err(_) => hex_str.to_string(), + } + } else if let Some(arr) = payload.as_array() { + // Array of byte values + let bytes: Vec = arr + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + match String::from_utf8(bytes.clone()) { + Ok(text) => text, + Err(_) => format!("0x{}", const_hex::encode(&bytes)), + } + } else { + serde_json::to_string_pretty(payload).unwrap_or_else(|_| payload.to_string()) + } + } else { + // No "payload" field -- return the full response object + serde_json::to_string_pretty(first).unwrap_or_else(|_| first.to_string()) + }; + + Ok(CallToolResult { + content: vec![Content::text(payload_display)], + is_error: Some(false), + }) + } + + TrustTier::SignedResult => { + // ── Execute component (same as Tier 1) ────────────────────── + let trigger = serde_json::json!({"manual": null}); + let input_bytes = serde_json::to_vec(&input).unwrap_or_default(); + let data = serde_json::json!({"Raw": input_bytes}); + + let execute_fut = + ctx.client + .execute_component(&service_id, &workflow_id, &trigger, &data); + + let result = + match tokio::time::timeout(Duration::from_millis(timeout_ms), execute_fut).await { + Err(_elapsed) => { + return exec_error( + ERR_EXECUTION_TIMEOUT, + &format!("Component execution timed out after {timeout_ms}ms"), + None, + ); + } + Ok(Err(e)) => { + return exec_error( + ERR_COMPONENT_FAILED, + &format!( + "Component execution failed for {service_name}/{workflow_id}: {e:#}" + ), + None, + ); + } + Ok(Ok(responses)) => responses, + }; + + if result.is_empty() { + return exec_error( + ERR_COMPONENT_FAILED, + "Component returned no responses", + None, + ); + } + + // Extract payload bytes from the first response + let first = &result[0]; + let payload = extract_payload_bytes(first); + + // ── Get signing credential ────────────────────────────────── + let credential = match ctx.signing_mnemonic { + Some(c) => c, + None => { + return exec_error( + ERR_SIGNING_FAILED, + "Tier 2 requires --signing-mnemonic (WAVS_SIGNING_MNEMONIC) on the MCP server", + Some(&payload), + ); + } + }; + + // ── Get HD index for the service from the WAVS node ───────── + let service_obj = find_service_obj(ctx.services_json, &service_id); + let service_manager: ServiceManager = match service_obj + .and_then(|s| s.get("manager")) + .and_then(|m| serde_json::from_value(m.clone()).ok()) + { + Some(m) => m, + None => { + return exec_error( + ERR_SIGNING_FAILED, + "Could not parse service manager from service definition", + Some(&payload), + ); + } + }; + + let signer_resp = match ctx.client.get_service_signer(service_manager).await { + Ok(r) => r, + Err(e) => { + return exec_error( + ERR_SIGNING_FAILED, + &format!("Failed to get service signer: {e:#}"), + Some(&payload), + ); + } + }; + + let hd_index = match signer_resp { + wavs_types::SignerResponse::Secp256k1 { hd_index, .. } => hd_index, + }; + + // ── Derive the signing key ────────────────────────────────── + let signer = match make_signer(credential, Some(hd_index)) { + Ok(s) => s, + Err(e) => { + return exec_error( + ERR_SIGNING_FAILED, + &format!("Failed to derive signing key: {e:#}"), + Some(&payload), + ); + } + }; + + // ── Sign the payload ──────────────────────────────────────── + let raw_payload = RawPayload(payload.clone()); + let signature = match wavs_types::WavsSigner::sign( + &raw_payload, + &signer, + SignatureKind::evm_default(), + ) + .await + { + Ok(sig) => sig, + Err(e) => { + return exec_error( + ERR_SIGNING_FAILED, + &format!("Signing failed: {e:#}"), + Some(&payload), + ); + } + }; + + // ── Build response envelope (D-06, hex-encoded) ───────────── + let signed_result = serde_json::json!({ + "result": const_hex::encode(&payload), + "signature": format!("0x{}", const_hex::encode(&signature.data)), + "signer_address": format!("{}", signer.address()), + "algorithm": "secp256k1", + "prefix": "eip191", + }); + ok(serde_json::to_string_pretty(&signed_result).unwrap()) + } + + TrustTier::OnChain => { + // ── Check per-service exec_enabled gating (D-10) ──────────── + let service_obj = find_service_obj(ctx.services_json, &service_id); + let exec_enabled = service_obj + .and_then(|s| s.get("exec_enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !exec_enabled { + return exec_error( + ERR_TIER_NOT_ENABLED, + "on_chain tier not enabled for this service \ + -- set exec_enabled: true in service.json (per D-10)", + None, + ); + } + + // ── Check if this is a confirmation (second step) ─────────── + let confirm_nonce = args_map + .get("confirm") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let pending_confirmations = match ctx.pending_confirmations { + Some(pc) => pc, + None => { + return exec_error( + ERR_SUBMISSION_FAILED, + "Internal error: pending confirmations not initialized", + None, + ); + } + }; + + if let Some(nonce) = confirm_nonce { + // === CONFIRMATION STEP (second call) ===================== + let pending = match pending_confirmations.take(&nonce).await { + Some(p) => p, + None => { + return exec_error( + ERR_SUBMISSION_FAILED, + "Confirmation nonce expired or invalid. \ + Re-execute with trust_tier: on_chain to get a new estimate.", + None, + ); + } + }; + + let credential = match ctx.mcp_chain_credential { + Some(c) => c, + None => { + return exec_error( + ERR_SUBMISSION_FAILED, + "On-chain submission requires --mcp-chain-credential \ + (WAVS_MCP_CHAIN_CREDENTIAL)", + Some(&pending.payload), + ); + } + }; + + // Determine RPC URL + let rpc_url = match &pending.rpc_url { + Some(url) => url.clone(), + None => { + // Fallback: try to get chains from the WAVS node + match get_chain_rpc_url(ctx.client, &pending.chain_id).await { + Ok(url) => url, + Err(_) => { + return exec_error( + ERR_SUBMISSION_FAILED, + &format!( + "Could not determine RPC URL for chain '{}'. \ + Ensure the WAVS node has chain config for this chain.", + pending.chain_id + ), + Some(&pending.payload), + ); + } + } + } + }; + + // Submit on-chain via EvmSigningClient + let endpoint: EvmEndpoint = match rpc_url.parse() { + Ok(ep) => ep, + Err(e) => { + return exec_error( + ERR_SUBMISSION_FAILED, + &format!("Invalid RPC URL '{rpc_url}': {e:#}"), + Some(&pending.payload), + ); + } + }; + + let config = EvmSigningClientConfig::new(endpoint, credential.clone()); + let client = match EvmSigningClient::new(config).await { + Ok(c) => c, + Err(e) => { + return exec_error( + ERR_SUBMISSION_FAILED, + &format!("Failed to create signing client: {e:#}"), + Some(&pending.payload), + ); + } + }; + + // Build transaction: self-transfer with result data in input field + let result_hash = alloy_primitives::keccak256(&pending.payload); + let tx_data = alloy_primitives::Bytes::from( + [ + pending.service_id.as_bytes(), + pending.workflow_id.as_bytes(), + result_hash.as_slice(), + ] + .concat(), + ); + + let from_address = client.address(); + let tx = alloy_rpc_types_eth::TransactionRequest::default() + .to(from_address) + .input(tx_data.into()); + + let receipt = match client + .provider + .send_transaction(tx) + .await + .map_err(|e| { + exec_error_value( + ERR_SUBMISSION_FAILED, + &format!("Transaction send failed: {e:#}"), + Some(&pending.payload), + ) + })? + .get_receipt() + .await + { + Ok(r) => r, + Err(e) => { + return exec_error( + ERR_SUBMISSION_FAILED, + &format!("Transaction receipt failed: {e:#}"), + Some(&pending.payload), + ); + } + }; + + let tx_hash = format!("{}", receipt.transaction_hash); + + let result = serde_json::json!({ + "status": "submitted", + "tx_hash": tx_hash, + "chain_id": pending.chain_id, + "service_id": pending.service_id, + "workflow_id": pending.workflow_id, + "result_hex": const_hex::encode(&pending.payload), + }); + return ok(serde_json::to_string_pretty(&result).unwrap()); + } + + // === ESTIMATE STEP (first call) ============================== + let trigger = serde_json::json!({"manual": null}); + let input_bytes = serde_json::to_vec(&input).unwrap_or_default(); + let data = serde_json::json!({"Raw": input_bytes}); + + let execute_fut = + ctx.client + .execute_component(&service_id, &workflow_id, &trigger, &data); + + let result = + match tokio::time::timeout(Duration::from_millis(timeout_ms), execute_fut).await { + Err(_elapsed) => { + return exec_error( + ERR_EXECUTION_TIMEOUT, + &format!("Component execution timed out after {timeout_ms}ms"), + None, + ); + } + Ok(Err(e)) => { + return exec_error( + ERR_COMPONENT_FAILED, + &format!( + "Component execution failed for {service_name}/{workflow_id}: {e:#}" + ), + None, + ); + } + Ok(Ok(responses)) => responses, + }; + + if result.is_empty() { + return exec_error( + ERR_COMPONENT_FAILED, + "Component returned no responses", + None, + ); + } + + let first = &result[0]; + let payload = extract_payload_bytes(first); + + // Determine chain_id and service_manager_address from services_json + let (chain_id, sm_address, rpc_url) = match service_obj + .and_then(|s| s.get("manager")) + .and_then(|m| serde_json::from_value::(m.clone()).ok()) + { + Some(ServiceManager::Evm { chain, address }) => ( + chain.to_string(), + format!("{address}"), + get_chain_rpc_url(ctx.client, &chain.to_string()).await.ok(), + ), + Some(ServiceManager::Cosmos { chain, .. }) => { + (chain.to_string(), String::new(), None) + } + None => ("unknown".to_string(), String::new(), None), + }; + + // Gas estimation (static for v1) + let gas_estimate = match ctx.mcp_chain_credential { + Some(_) => "~300000 gas (estimate)".to_string(), + None => { + "~300000 gas (estimate -- provide --mcp-chain-credential for actual estimation)" + .to_string() + } + }; + + // Store in pending confirmations cache + let pending = PendingExecution { + service_id: service_id.clone(), + workflow_id: workflow_id.clone(), + payload: payload.clone(), + gas_estimate: gas_estimate.clone(), + chain_id: chain_id.clone(), + service_manager_address: sm_address.clone(), + rpc_url, + created_at: Instant::now(), + }; + let nonce = pending_confirmations.store(pending).await; + + // Return estimate response (D-09) + let estimate = serde_json::json!({ + "status": "estimate", + "nonce": nonce, + "gas_estimate": gas_estimate, + "chain_id": chain_id, + "service_manager_address": sm_address, + "result_preview_hex": const_hex::encode(&payload[..payload.len().min(64)]), + "expires_in_seconds": 60, + "instructions": format!( + "To submit on-chain, call this tool again with trust_tier: \"on_chain\" and confirm: \"{}\"", + nonce + ) + }); + ok(serde_json::to_string_pretty(&estimate).unwrap()) + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/// Find the service JSON object in the services map by service_id (hex key). +fn find_service_obj<'a>( + services_json: &'a serde_json::Value, + service_id: &str, +) -> Option<&'a serde_json::Value> { + services_json.as_object()?.get(service_id) +} + +/// Extract raw payload bytes from a response object. +/// +/// The `/dev/execute` response items have a `payload` field that is either +/// a hex string or an array of byte values. +fn extract_payload_bytes(response: &serde_json::Value) -> Vec { + if let Some(payload) = response.get("payload") { + if let Some(hex_str) = payload.as_str() { + if let Ok(bytes) = const_hex::decode(hex_str) { + return bytes; + } + } + if let Some(arr) = payload.as_array() { + return arr + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + } + } + Vec::new() +} + +/// Get the RPC URL for a given chain key from the WAVS node. +/// +/// Queries `GET /chains` and parses the chain config. Falls back to +/// well-known defaults for local development chains. +async fn get_chain_rpc_url(client: &WavsClient, chain_key: &str) -> Result { + // Try getting chains from the WAVS node + if let Ok(chains) = client.get_chains().await { + // chains is typically a map of chain_key -> config with rpc_url + if let Some(obj) = chains.as_object() { + if let Some(chain_config) = obj.get(chain_key) { + if let Some(url) = chain_config + .get("rpc_url") + .or_else(|| chain_config.get("endpoint")) + .and_then(|v| v.as_str()) + { + return Ok(url.to_string()); + } + } + } + } + + // Fallback for well-known local chains + if chain_key.contains("31337") || chain_key.contains("anvil") { + return Ok("http://localhost:8545".to_string()); + } + + Err(McpError { + code: ErrorCode::INTERNAL_ERROR, + message: format!("No RPC URL configured for chain '{chain_key}'").into(), + data: None, + }) +} + +/// Return a successful `CallToolResult` with a text content body. +fn ok(text: impl Into) -> Result { + Ok(CallToolResult { + content: vec![Content::text(text.into())], + is_error: Some(false), + }) +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_basic() { + assert_eq!(sanitize_tool_name("My Service!"), "my_service"); + assert_eq!(sanitize_tool_name("hello-world"), "hello_world"); + assert_eq!(sanitize_tool_name("___leading"), "leading"); + assert_eq!(sanitize_tool_name("trailing___"), "trailing"); + assert_eq!(sanitize_tool_name("a--b..c"), "a_b_c"); + } + + #[test] + fn sanitize_truncation() { + let long = "a".repeat(100); + let sanitized = sanitize_tool_name(&long); + assert!(sanitized.len() <= 64); + } + + #[test] + fn merge_schema_has_required_fields() { + let wit = serde_json::json!({"type": "object", "properties": {"msg": {"type": "string"}}}); + let merged = merge_exec_schema(wit); + let obj = merged.as_object().unwrap(); + assert!(obj.contains_key("properties")); + let props = obj["properties"].as_object().unwrap(); + assert!(props.contains_key("input")); + assert!(props.contains_key("trust_tier")); + assert!(props.contains_key("timeout_ms")); + assert!(props.contains_key("confirm")); + let required = obj["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::json!("trust_tier"))); + } + + #[test] + fn build_exec_tools_generates_tools_from_services() { + let services = serde_json::json!({ + "abc123": { + "name": "My Echo Service", + "workflows": { + "default": { + "component": { + "source": {"digest": "f0b42a5171c9dcd75eac41c8ce2c4e7882d304c885266d8ac7b70af996b9a420"} + } + } + } + } + }); + let tools = build_exec_tools(&services); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name.as_ref(), "wavs_exec_my_echo_service_default"); + let desc: &str = tools[0].description.as_ref(); + assert!(desc.contains("My Echo Service")); + assert!(desc.contains("component:f0b42a5171c9")); + } + + #[test] + fn build_exec_tools_empty_services() { + let tools = build_exec_tools(&serde_json::json!({})); + assert!(tools.is_empty()); + } + + #[test] + fn build_exec_tools_multiple_workflows() { + let services = serde_json::json!({ + "svc1": { + "name": "Multi-Workflow", + "workflows": { + "default": { + "component": {"source": {"digest": "aabb"}} + }, + "secondary": { + "component": {"source": {"oci": {"uri": "ghcr.io/foo/bar:latest"}}} + } + } + } + }); + let tools = build_exec_tools(&services); + assert_eq!(tools.len(), 2); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"wavs_exec_multi_workflow_default")); + assert!(names.contains(&"wavs_exec_multi_workflow_secondary")); + } + + #[test] + fn resolve_tool_service_finds_match() { + let services = serde_json::json!({ + "abc123": { + "name": "Echo Service", + "workflows": { + "default": { + "component": {"source": {"digest": "deadbeef"}} + } + } + } + }); + let result = resolve_tool_service("wavs_exec_echo_service_default", &services); + assert!(result.is_some()); + let (sid, wid, name, _source) = result.unwrap(); + assert_eq!(sid, "abc123"); + assert_eq!(wid, "default"); + assert_eq!(name, "Echo Service"); + } + + #[test] + fn resolve_tool_service_returns_none_for_unknown() { + let services = serde_json::json!({ + "abc123": { + "name": "Echo Service", + "workflows": { + "default": { + "component": {"source": {"digest": "deadbeef"}} + } + } + } + }); + assert!(resolve_tool_service("wavs_exec_nonexistent_default", &services).is_none()); + } + + #[test] + fn component_source_desc_variants() { + assert_eq!( + component_source_desc( + &serde_json::json!({"component": {"source": {"oci": {"uri": "ghcr.io/test:v1"}}}}) + ), + "ghcr.io/test:v1" + ); + assert_eq!( + component_source_desc( + &serde_json::json!({"component": {"source": {"digest": "abcdef123456789012"}}}) + ), + "component:abcdef123456" + ); + assert_eq!( + component_source_desc( + &serde_json::json!({"component": {"source": {"download": {"uri": "https://example.com/comp.wasm"}}}}) + ), + "https://example.com/comp.wasm" + ); + assert_eq!( + component_source_desc(&serde_json::json!({"component": {"source": {}}})), + "local" + ); + } +} diff --git a/packages/wavs-mcp/src/main.rs b/packages/wavs-mcp/src/main.rs index 93fd0d2f4..940fc9fbb 100644 --- a/packages/wavs-mcp/src/main.rs +++ b/packages/wavs-mcp/src/main.rs @@ -1,5 +1,6 @@ mod chain_ops; mod client; +mod exec; mod scaffold; mod server; @@ -33,6 +34,12 @@ struct Args { /// Falls back to `signing_mnemonic` in the [wavs] section of ~/.wavs/wavs.toml. #[arg(long, env = "WAVS_SIGNING_MNEMONIC")] signing_mnemonic: Option, + + /// Enable execution tools (wavs_exec_*). When disabled, only management tools are available. + /// This is a safety gate -- execution tools can invoke component logic and (for Tier 3) + /// submit on-chain transactions. + #[arg(long, env = "WAVS_EXEC_ENABLED", default_value = "false")] + exec_enabled: bool, } /// Read a credential field from the [wavs] section of wavs.toml, searching only @@ -108,6 +115,7 @@ async fn main() -> anyhow::Result<()> { args.token, args.mcp_chain_credential, args.signing_mnemonic, + args.exec_enabled, ); serve_server(server, stdio()) diff --git a/packages/wavs-mcp/src/scaffold.rs b/packages/wavs-mcp/src/scaffold.rs index 4482a5617..9cfc1d60b 100644 --- a/packages/wavs-mcp/src/scaffold.rs +++ b/packages/wavs-mcp/src/scaffold.rs @@ -1,3 +1,6 @@ +use std::fs; +use std::path::{Path, PathBuf}; + /// Returns the main WAVS WIT interface definitions. /// Used by `wavs_get_wit_interface` to give AI assistants full knowledge of /// available WASM APIs (HTTP, KV, sockets, TLS, host functions, etc.). @@ -23,175 +26,526 @@ pub fn get_wit_interface() -> String { ) } -/// Generate a scaffold WASM component project. -/// Returns a formatted string containing the Cargo.toml and lib.rs for the component. -pub fn scaffold_component(name: &str, trigger_type: &str, description: Option<&str>) -> String { +/// All WIT dependency files bundled at compile time. +const WIT_DEPS: &[(&str, &str)] = &[ + ( + "wasi-cli-0.2.0", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-cli-0.2.0/package.wit"), + ), + ( + "wasi-clocks-0.2.0", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-clocks-0.2.0/package.wit"), + ), + ( + "wasi-filesystem-0.2.0", + include_str!( + "../../../wit-definitions/operator/wit/deps/wasi-filesystem-0.2.0/package.wit" + ), + ), + ( + "wasi-http-0.2.0", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-http-0.2.0/package.wit"), + ), + ( + "wasi-io-0.2.0", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-io-0.2.0/package.wit"), + ), + ( + "wasi-keyvalue-0.2.0-draft2", + include_str!( + "../../../wit-definitions/operator/wit/deps/wasi-keyvalue-0.2.0-draft2/package.wit" + ), + ), + ( + "wasi-random-0.2.0", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-random-0.2.0/package.wit"), + ), + ( + "wasi-sockets-0.2.0", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-sockets-0.2.0/package.wit"), + ), + ( + "wasi-tls-0.2.0-draft", + include_str!("../../../wit-definitions/operator/wit/deps/wasi-tls-0.2.0-draft/package.wit"), + ), + ( + "wavs-types-2.7.0", + include_str!("../../../wit-definitions/operator/wit/deps/wavs-types-2.7.0/package.wit"), + ), +]; + +const OPERATOR_WIT: &str = include_str!("../../../wit-definitions/operator/wit/operator.wit"); + +// --------------------------------------------------------------------------- +// Return scaffold as text (no disk writes) +// --------------------------------------------------------------------------- + +/// Return all file contents as a formatted text block for the agent to write manually. +pub fn scaffold_component_text( + name: &str, + trigger_type: &str, + description: Option<&str>, +) -> String { let desc = description.unwrap_or("A WAVS WASM component"); + let underscored = name.replace('-', "_"); + let cargo_toml = generate_cargo_toml(name); - let lib_rs = generate_lib_rs(name, trigger_type, desc); + let lib_rs = generate_lib_rs(trigger_type, desc); + + let mut wit_sections = String::new(); + wit_sections.push_str(&format!( + "### `{name}/wit/operator.wit`\n```wit\n{OPERATOR_WIT}\n```\n\n" + )); + for (dep_name, content) in WIT_DEPS { + wit_sections.push_str(&format!( + "### `{name}/wit/deps/{dep_name}/package.wit`\n```wit\n{content}\n```\n\n" + )); + } format!( "# Scaffold: `{name}` ({trigger_type})\n\n\ {desc}\n\n\ - ## `Cargo.toml`\n\ - ```toml\n{cargo_toml}\n```\n\n\ - ## `src/lib.rs`\n\ - ```rust\n{lib_rs}\n```\n\n\ - ## Next steps\n\ - 1. Create directory: `mkdir -p examples/components/{name}/src`\n\ - 2. Write the files above\n\ - 3. Add the crate to the workspace in the root `Cargo.toml`\n\ - 4. Build: `cargo component build --release -p {name}`\n\ - 5. Upload: use `wavs_upload_component` with the compiled `.wasm` path\n\ - 6. Deploy: use `wavs_deploy_service` with the service manager address" + **Write ALL files below exactly as shown.** The WIT files and bindings.rs must not be modified.\n\ + Build with: `cargo build --target wasm32-wasip2 --release`\n\ + Prerequisite: `rustup target add wasm32-wasip2`\n\n\ + > **Tip:** Call this tool again with `dir` parameter to write files to disk automatically.\n\n\ + ## Directory structure\n\ + ```\n\ + {name}/\n\ + ├── Cargo.toml\n\ + ├── src/\n\ + │ ├── lib.rs\n\ + │ └── bindings.rs\n\ + └── wit/\n\ + ├── operator.wit\n\ + └── deps/ (10 packages)\n\ + ```\n\n\ + ### `{name}/Cargo.toml`\n\ + ```toml\n{cargo_toml}```\n\n\ + ### `{name}/src/lib.rs`\n\ + ```rust\n{lib_rs}```\n\n\ + ### `{name}/src/bindings.rs`\n\ + ```rust\n{BINDINGS_RS}```\n\n\ + {wit_sections}\ + ## Build\n\ + ```bash\n\ + cd {name}\n\ + cargo build --target wasm32-wasip2 --release\n\ + # Output: target/wasm32-wasip2/release/{underscored}.wasm\n\ + ```\n", ) } +// --------------------------------------------------------------------------- +// Write scaffold to disk +// --------------------------------------------------------------------------- + +/// Create a complete, self-contained WAVS component project on disk. +/// +/// Writes all files needed to build immediately: +/// - `Cargo.toml` with direct dependencies (no workspace) +/// - `src/lib.rs` with trigger-specific template code +/// - `src/bindings.rs` with wit-bindgen generation +/// - `wit/operator.wit` and all `wit/deps/*/package.wit` files +/// +/// Returns a summary string describing what was created and how to build. +pub fn scaffold_component_to_disk( + name: &str, + trigger_type: &str, + parent_dir: &str, + description: Option<&str>, +) -> Result { + let desc = description.unwrap_or("A WAVS WASM component"); + let project_dir = PathBuf::from(parent_dir).join(name); + + if project_dir.exists() { + return Err(format!( + "Directory already exists: {}. Remove it first or choose a different name.", + project_dir.display() + )); + } + + // Create directory structure + let src_dir = project_dir.join("src"); + let wit_dir = project_dir.join("wit"); + let wit_deps_dir = wit_dir.join("deps"); + + create_dir(&src_dir)?; + for (dep_name, _) in WIT_DEPS { + create_dir(&wit_deps_dir.join(dep_name))?; + } + + // Write Cargo.toml + write_file(&project_dir.join("Cargo.toml"), &generate_cargo_toml(name))?; + + // Write src/lib.rs + write_file( + &src_dir.join("lib.rs"), + &generate_lib_rs(trigger_type, desc), + )?; + + // Write src/bindings.rs + write_file(&src_dir.join("bindings.rs"), BINDINGS_RS)?; + + // Write wit/operator.wit + write_file(&wit_dir.join("operator.wit"), OPERATOR_WIT)?; + + // Write all WIT dependency files + for (dep_name, content) in WIT_DEPS { + write_file(&wit_deps_dir.join(dep_name).join("package.wit"), content)?; + } + + let underscored = name.replace('-', "_"); + let abs_path = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.clone()); + + Ok(format!( + "# ✅ Component `{name}` created successfully\n\n\ + **Location:** `{path}`\n\n\ + ## Files written\n\ + ```\n\ + {name}/\n\ + ├── Cargo.toml\n\ + ├── src/\n\ + │ ├── lib.rs ← your component logic (customize this)\n\ + │ └── bindings.rs ← auto-generated WAVS bindings (do not edit)\n\ + └── wit/ ← WAVS interface definitions (do not edit)\n\ + ├── operator.wit\n\ + └── deps/ (10 packages)\n\ + ```\n\n\ + ## Next steps\n\n\ + 1. **Customize** `src/lib.rs` with your component logic\n\ + 2. **Build:** `wavs_build_component` with dir=`{path}`\n\ + 3. **Validate:** `wavs_validate_component` with wasm_path=`{path}/target/wasm32-wasip2/release/{underscored}.wasm`\n\ + 4. **Upload:** `wavs_upload_component` with the .wasm path\n\ + 5. **Deploy:** `wavs_deploy_dev_service` with the returned digest\n\ + 6. **Test:** `wavs_simulate_trigger` to verify\n\n\ + ## Build command (manual)\n\ + ```bash\n\ + cd {path}\n\ + rustup target add wasm32-wasip2 # one-time setup\n\ + cargo build --target wasm32-wasip2 --release\n\ + ```\n\n\ + ## Trigger type: `{trigger_type}`\n\ + The generated `src/lib.rs` handles `{trigger_type}` triggers.\n\ + Edit the `match action.data` block to implement your logic.\n", + path = abs_path.display(), + underscored = underscored, + trigger_type = trigger_type, + )) +} + +fn create_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|e| format!("Failed to create directory {}: {e}", path.display())) +} + +fn write_file(path: &Path, content: &str) -> Result<(), String> { + fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display())) +} + +// --------------------------------------------------------------------------- +// File templates +// --------------------------------------------------------------------------- + fn generate_cargo_toml(name: &str) -> String { format!( r#"[package] name = "{name}" -edition.workspace = true +version = "0.1.0" +edition = "2021" + +[dependencies] +wit-bindgen = {{ version = "0.53.1", features = ["bitflags"] }} +wit-bindgen-rt = {{ version = "0.44.0", features = ["bitflags"] }} +serde = {{ version = "1", features = ["derive"] }} +serde_json = "1" +anyhow = "1" [lib] crate-type = ["cdylib"] -[package.metadata.component] -package = "component:{name}" - -[dependencies] -example-helpers = {{ workspace = true }} +[profile.release] +codegen-units = 1 +opt-level = "s" +debug = false +strip = true +lto = true "# ) } -fn generate_lib_rs(_name: &str, trigger_type: &str, description: &str) -> String { - match trigger_type { - "cron" => format!( - r#"// {description} -use example_helpers::bindings::world::{{ - host, - wavs::operator::{{ - input::{{TriggerAction, TriggerData}}, - output::WasmResponse, - }}, - Guest, -}}; -use example_helpers::export_layer_trigger_world; -use example_helpers::trigger::encode_trigger_output; - -struct Component; +const BINDINGS_RS: &str = r#"#[allow(warnings)] +mod _inner { + wit_bindgen::generate!({ + world: "wavs-world", + path: "wit", + pub_export_macro: true, + generate_all, + features: ["tls"], + }); +} +pub use _inner::*; +"#; -impl Guest for Component {{ - fn run(trigger_action: TriggerAction) -> std::result::Result, String> {{ - if let TriggerData::Cron(cron) = trigger_action.data {{ - // cron.trigger_time.nanos is the scheduled unix timestamp in nanoseconds - let output = cron.trigger_time.nanos.to_be_bytes().to_vec(); - - Ok(vec![encode_trigger_output( - 0, - output, - host::get_service().service.manager, - )]) - }} else {{ - Err("Expected Cron trigger data".to_string()) - }} - }} -}} +fn generate_lib_rs(trigger_type: &str, desc: &str) -> String { + let (imports, body) = trigger_match_code(trigger_type); -export_layer_trigger_world!(Component); -"# - ), + format!( + r#"// {desc} +#[allow(warnings)] +mod bindings; - "block_interval" => format!( - r#"// {description} -use example_helpers::bindings::world::{{ - host, - wavs::operator::{{ - input::{{Trigger, TriggerAction, TriggerData}}, - output::WasmResponse, - }}, - Guest, -}}; -use example_helpers::export_layer_trigger_world; -use example_helpers::trigger::encode_trigger_output; +{imports} struct Component; +bindings::export!(Component with_types_in bindings); impl Guest for Component {{ - fn run(trigger_action: TriggerAction) -> std::result::Result, String> {{ - match (trigger_action.config.trigger, trigger_action.data) {{ - (Trigger::BlockInterval(_config), TriggerData::BlockInterval(data)) => {{ - // data.block_height is the block number that fired this trigger - let output = data.block_height.to_be_bytes().to_vec(); - - Ok(vec![encode_trigger_output( - 0, - output, - host::get_service().service.manager, - )]) - }} - _ => Err("Invalid trigger data".to_string()), - }} + fn run(action: TriggerAction) -> std::result::Result, String> {{ +{body} }} }} +"#, + ) +} -export_layer_trigger_world!(Component); -"# - ), +// --------------------------------------------------------------------------- +// Trigger-specific code generation +// --------------------------------------------------------------------------- - _ => { - let trigger_comment = match trigger_type { - "evm_contract_event" => { - "// `data` contains the ABI-encoded EVM event log bytes.\n \ - // Use alloy-sol-types or manual ABI decoding to parse the event." - } - "cosmos_contract_event" => { - "// `data` contains the serialized Cosmos contract event bytes.\n \ - // Deserialize using serde_json or the CosmWasm event format." - } - _ => { - "// `data` contains the raw trigger payload bytes.\n \ - // The exact format depends on the trigger configuration." - } - }; - - format!( - r#"// {description} -use example_helpers::bindings::world::{{ - host, - wavs::operator::{{ - input::{{TriggerAction, TriggerData}}, - output::WasmResponse, - }}, - Guest, -}}; -use example_helpers::export_layer_trigger_world; -use example_helpers::trigger::{{decode_trigger_event, encode_trigger_output}}; +#[cfg(test)] +mod tests { + use super::*; -struct Component; + #[test] + fn test_scaffold_to_disk_and_build() { + let tmp = std::env::temp_dir().join("wavs-scaffold-test"); + if tmp.exists() { + fs::remove_dir_all(&tmp).unwrap(); + } + fs::create_dir_all(&tmp).unwrap(); -impl Guest for Component {{ - fn run(trigger_action: TriggerAction) -> std::result::Result, String> {{ - let (trigger_id, data) = decode_trigger_event(trigger_action.data) - .map_err(|e| e.to_string())?; + // Test each trigger type scaffolds without error + for trigger in &[ + "manual", + "cron", + "block_interval", + "evm_contract_event", + "cosmos_contract_event", + ] { + let name = format!("test-{}", trigger.replace('_', "-")); + let result = scaffold_component_to_disk( + &name, + trigger, + tmp.to_str().unwrap(), + Some("Test component"), + ); + assert!( + result.is_ok(), + "scaffold failed for {trigger}: {}", + result.unwrap_err() + ); - {trigger_comment} + let project = tmp.join(&name); + assert!( + project.join("Cargo.toml").exists(), + "missing Cargo.toml for {trigger}" + ); + assert!( + project.join("src/lib.rs").exists(), + "missing lib.rs for {trigger}" + ); + assert!( + project.join("src/bindings.rs").exists(), + "missing bindings.rs for {trigger}" + ); + assert!( + project.join("wit/operator.wit").exists(), + "missing operator.wit for {trigger}" + ); + assert!( + project + .join("wit/deps/wavs-types-2.7.0/package.wit") + .exists(), + "missing wavs-types for {trigger}" + ); - // TODO: process `data` and compute your output - let output = data; + // Verify 10 WIT dep directories + let deps: Vec<_> = fs::read_dir(project.join("wit/deps")).unwrap().collect(); + assert_eq!( + deps.len(), + 10, + "expected 10 WIT deps for {trigger}, got {}", + deps.len() + ); + } - Ok(vec![encode_trigger_output( - trigger_id, - output, - host::get_service().service.manager, - )]) - }} -}} + // Verify duplicate directory is rejected + let dup = scaffold_component_to_disk("test-manual", "manual", tmp.to_str().unwrap(), None); + assert!(dup.is_err(), "should reject duplicate directory"); -export_layer_trigger_world!(Component); -"# - ) - } + // Clean up + fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn test_scaffold_text_mode() { + let text = scaffold_component_text("my-comp", "manual", None); + assert!(text.contains("Cargo.toml"), "should contain Cargo.toml"); + assert!(text.contains("bindings.rs"), "should contain bindings.rs"); + assert!(text.contains("operator.wit"), "should contain operator.wit"); + assert!( + text.contains("wavs-types-2.7.0"), + "should contain wavs-types" + ); + assert!( + text.contains("wasm32-wasip2"), + "should mention wasip2 target" + ); } } + +fn trigger_match_code(trigger_type: &str) -> (String, String) { + let imports = "use crate::bindings::{\n \ + wavs::types::events::TriggerData,\n \ + Guest, TriggerAction, WasmResponse,\n\ + };" + .to_string(); + + let body = match trigger_type { + "cron" => "\ + match action.data { + TriggerData::Cron(data) => { + let timestamp_nanos = data.trigger_time.nanos; + + // TODO: Implement your cron logic here + let output = serde_json::json!({ + \"triggered_at_nanos\": timestamp_nanos, + }); + + let payload = serde_json::to_vec(&output) + .map_err(|e| e.to_string())?; + + Ok(vec![WasmResponse { + payload, + ordering: None, + event_id_salt: None, + }]) + } + _ => Err(\"Expected Cron trigger data\".to_string()), + }" + .to_string(), + + "block_interval" => "\ + match action.data { + TriggerData::BlockInterval(data) => { + let block_height = data.block_height; + let chain = data.chain; + + // TODO: Implement your block interval logic here + let output = serde_json::json!({ + \"block_height\": block_height, + \"chain\": chain, + }); + + let payload = serde_json::to_vec(&output) + .map_err(|e| e.to_string())?; + + Ok(vec![WasmResponse { + payload, + ordering: None, + event_id_salt: None, + }]) + } + _ => Err(\"Expected BlockInterval trigger data\".to_string()), + }" + .to_string(), + + "evm_contract_event" => "\ + match action.data { + TriggerData::EvmContractEvent(event_data) => { + let chain = &event_data.chain; + let log_data = &event_data.log.data.data; + + // TODO: Decode the ABI-encoded event log data + // Use alloy-sol-types or manual ABI decoding to parse the event. + // The raw log data bytes are in `log_data`. + + let output = serde_json::json!({ + \"chain\": chain, + \"data_len\": log_data.len(), + }); + + let payload = serde_json::to_vec(&output) + .map_err(|e| e.to_string())?; + + Ok(vec![WasmResponse { + payload, + ordering: None, + event_id_salt: None, + }]) + } + _ => Err(\"Expected EvmContractEvent trigger data\".to_string()), + }" + .to_string(), + + "cosmos_contract_event" => "\ + match action.data { + TriggerData::CosmosContractEvent(event_data) => { + let chain = &event_data.chain; + let event = &event_data.event; + + // TODO: Process the Cosmos contract event + // event.ty is the event type string + // event.attributes is a Vec of (key, value) tuples + + let output = serde_json::json!({ + \"chain\": chain, + \"event_type\": event.ty, + \"block_height\": event_data.block_height, + }); + + let payload = serde_json::to_vec(&output) + .map_err(|e| e.to_string())?; + + Ok(vec![WasmResponse { + payload, + ordering: None, + event_id_salt: None, + }]) + } + _ => Err(\"Expected CosmosContractEvent trigger data\".to_string()), + }" + .to_string(), + + // "manual" or anything else + _ => "\ + match action.data { + TriggerData::Raw(data) => { + let input = std::str::from_utf8(&data) + .unwrap_or(\"\"); + + // TODO: Implement your component logic here + let output = serde_json::json!({ + \"input\": input, + \"message\": \"Hello from the component!\", + }); + + let payload = serde_json::to_vec(&output) + .map_err(|e| e.to_string())?; + + Ok(vec![WasmResponse { + payload, + ordering: None, + event_id_salt: None, + }]) + } + _ => Err(\"Expected Raw trigger data (manual trigger)\".to_string()), + }" + .to_string(), + }; + + (imports, format!(" {body}")) +} diff --git a/packages/wavs-mcp/src/server.rs b/packages/wavs-mcp/src/server.rs index 0bbdb0668..79a287ce8 100644 --- a/packages/wavs-mcp/src/server.rs +++ b/packages/wavs-mcp/src/server.rs @@ -1,16 +1,84 @@ use std::sync::Arc; +use std::time::Duration; use rmcp::{ handler::server::tool::schema_for_type, model::*, schemars, - service::{RequestContext, RoleServer}, + service::{Peer, RequestContext, RoleServer}, ServerHandler, }; use serde::Deserialize; use crate::chain_ops; use crate::client::WavsClient; +use crate::exec; + +/// Serde helper: deserialize a number that may arrive as a JSON string (LLMs often quote numbers). +mod string_or_number { + use serde::{self, Deserialize, Deserializer}; + + pub fn deserialize_option_usize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNum { + Num(usize), + Str(String), + } + let opt: Option = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrNum::Num(n)) => Ok(Some(n)), + Some(StringOrNum::Str(s)) => s + .parse::() + .map(Some) + .map_err(serde::de::Error::custom), + } + } + + pub fn deserialize_option_u64<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNum { + Num(u64), + Str(String), + } + let opt: Option = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrNum::Num(n)) => Ok(Some(n)), + Some(StringOrNum::Str(s)) => { + s.parse::().map(Some).map_err(serde::de::Error::custom) + } + } + } + + pub fn deserialize_option_u32<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNum { + Num(u32), + Str(String), + } + let opt: Option = Option::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(StringOrNum::Num(n)) => Ok(Some(n)), + Some(StringOrNum::Str(s)) => { + s.parse::().map(Some).map_err(serde::de::Error::custom) + } + } + } +} use crate::scaffold; // ── Parameter structs ────────────────────────────────────────────────────── @@ -39,7 +107,9 @@ pub struct UploadComponentParams { #[derive(Deserialize, schemars::JsonSchema)] pub struct SimulateTriggerParams { - /// Service ID — 64-char hex string derived from the ServiceManager + /// Service ID — 64-char hex string derived from the ServiceManager. + /// This is returned by wavs_deploy_dev_service as `service_id`. + /// NOT the deploy_hash — the service_id is a different value. pub service_id: String, /// Workflow ID — lowercase alphanumeric, 3–36 chars (e.g. "default") pub workflow_id: String, @@ -49,6 +119,10 @@ pub struct SimulateTriggerParams { /// TriggerData as JSON, e.g. `{"Cron":{"trigger_time":0}}` pub data_json: String, /// How many times to fire the trigger (default: 1) + #[serde( + default, + deserialize_with = "string_or_number::deserialize_option_usize" + )] pub count: Option, } @@ -82,8 +156,13 @@ pub struct QueryKvParams { pub struct QueryLogsParams { /// Return only entries with id >= since_id. Pass the `next_id` from the previous response /// to page forward. Defaults to 0 (return from the oldest buffered entry). + #[serde(default, deserialize_with = "string_or_number::deserialize_option_u64")] pub since_id: Option, /// Maximum number of entries to return (default: 100, max: 1000). + #[serde( + default, + deserialize_with = "string_or_number::deserialize_option_usize" + )] pub limit: Option, /// Minimum log level filter: trace | debug | info | warn | error. /// Returns entries at this level and above (e.g. "info" includes warn + error). @@ -97,8 +176,13 @@ pub struct QueryLogsParams { pub struct QueryComponentLogsParams { /// Return only entries with id >= since_id. Pass the `next_id` from the previous response /// to page forward. Defaults to 0 (return from the oldest buffered entry). + #[serde(default, deserialize_with = "string_or_number::deserialize_option_u64")] pub since_id: Option, /// Maximum number of entries to return (default: 100, max: 1000). + #[serde( + default, + deserialize_with = "string_or_number::deserialize_option_usize" + )] pub limit: Option, /// Minimum log level filter: trace | debug | info | warn | error. pub level: Option, @@ -116,6 +200,10 @@ pub struct ScaffoldComponentParams { pub name: String, /// Trigger type: evm_contract_event | cosmos_contract_event | block_interval | cron | manual pub trigger_type: String, + /// Directory to create the project in. The component directory `{dir}/{name}/` will be created. + /// If omitted, returns the file contents as text instead of writing to disk. + /// Example: "/tmp" creates "/tmp/price-feed/" + pub dir: Option, /// Optional description of what this component does pub description: Option, } @@ -152,6 +240,7 @@ pub struct RegisterOperatorParams { /// Weight to assign to the operator (default: 100). /// Represents relative stake weight — higher weight = more influence in multi-operator consensus. /// For single-operator setups, any positive value works; 100 is conventional. + #[serde(default, deserialize_with = "string_or_number::deserialize_option_u64")] pub weight: Option, /// RPC endpoint URL for the chain (e.g. "http://localhost:8545") pub rpc_url: String, @@ -165,10 +254,17 @@ pub struct BuildComponentParams { pub release: Option, } +#[derive(Deserialize, schemars::JsonSchema)] +pub struct ValidateComponentParams { + /// Path to the compiled .wasm component file + pub wasm_path: String, +} + #[derive(Deserialize, schemars::JsonSchema)] pub struct GetSigningAddressParams { /// HD derivation index to use (default: 0). Use the hd_index reported by /// wavs_get_service_signer to check a service-specific signing key. + #[serde(default, deserialize_with = "string_or_number::deserialize_option_u32")] pub hd_index: Option, } @@ -178,6 +274,7 @@ pub struct DeployAndRegisterParams { /// EVM: `{"evm":{"chain":"evm:31337","address":"0xAbCd..."}}` pub service_manager_json: String, /// Weight to assign to the operator (default: 100). + #[serde(default, deserialize_with = "string_or_number::deserialize_option_u64")] pub weight: Option, /// RPC endpoint URL for the chain (e.g. "http://localhost:8545") pub rpc_url: String, @@ -198,7 +295,11 @@ fn err(text: impl Into) -> Result { fn parse_args( args: Option>, ) -> Result { - let value = serde_json::Value::Object(args.unwrap_or_default()); + let mut map = args.unwrap_or_default(); + // MCP clients (especially Claude) often send bools and numbers as strings. + // Coerce string values that look like bools/numbers to their native JSON types. + coerce_string_values(&mut map); + let value = serde_json::Value::Object(map); serde_json::from_value(value).map_err(|e| ErrorData { code: ErrorCode::INVALID_PARAMS, message: format!("Invalid parameters: {e}").into(), @@ -206,6 +307,75 @@ fn parse_args( }) } +/// Coerce string values that look like bools or numbers to native JSON types. +/// Handles: "true"/"false" → bool, "123" → number, "1.5" → number. +/// Only applies to top-level string values (not nested objects/arrays). +fn coerce_string_values(map: &mut serde_json::Map) { + for value in map.values_mut() { + if let serde_json::Value::String(s) = value { + match s.as_str() { + "true" => *value = serde_json::Value::Bool(true), + "false" => *value = serde_json::Value::Bool(false), + other => { + if let Ok(n) = other.parse::() { + *value = serde_json::Value::Number(n.into()); + } else if let Ok(n) = other.parse::() { + if let Some(n) = serde_json::Number::from_f64(n) { + *value = serde_json::Value::Number(n); + } + } + } + } + } + } +} + +/// Detect placeholder/example addresses that agents copy verbatim from schema examples. +/// Matches patterns like 0x1234567890..., 0xAbCdEf..., 0xServiceManagerAddress, etc. +fn is_placeholder_address(addr: &str) -> bool { + let lower = addr.to_lowercase(); + // Non-hex characters in the address part → clearly a placeholder like "0xServiceManagerAddress" + if let Some(hex_part) = lower.strip_prefix("0x") { + if hex_part.chars().any(|c| !c.is_ascii_hexdigit()) { + return true; + } + } + // Common sequential/repeating patterns agents generate + let patterns = [ + "0x1234567890", + "0xabcdef1234", + "0x0000000000", + "0xaaaaaaaaaa", + "0x1111111111", + ]; + for p in patterns { + if lower.starts_with(p) { + return true; + } + } + false +} + +/// Generate a unique hex string of the given length for use as a dev manager address. +/// Uses timestamp + process ID + counter for uniqueness (no `rand` crate needed). +fn random_hex(len: usize) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let pid = std::process::id() as u64; + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + + // Hash the values together to produce enough hex chars + let mut s = format!("{:016x}{:08x}{:016x}", nanos, pid, count); + s.truncate(len); + s +} + fn no_params() -> Arc> { Arc::new( serde_json::json!({"type": "object", "properties": {}}) @@ -234,6 +404,10 @@ pub struct WavsMcpServer { client: WavsClient, mcp_chain_credential: Option, signing_mnemonic: Option, + exec_enabled: bool, + service_cache: Arc, + peer: Arc>>>, + pending_confirmations: Arc, } impl WavsMcpServer { @@ -242,11 +416,16 @@ impl WavsMcpServer { token: Option, mcp_chain_credential: Option, signing_mnemonic: Option, + exec_enabled: bool, ) -> Self { Self { client: WavsClient::new(wavs_url, token), mcp_chain_credential, signing_mnemonic, + exec_enabled, + service_cache: Arc::new(exec::ServiceCache::new(Duration::from_secs(5))), + peer: Arc::new(tokio::sync::RwLock::new(None)), + pending_confirmations: Arc::new(exec::PendingConfirmations::new()), } } @@ -290,6 +469,32 @@ impl WavsMcpServer { }) } + // ── Service cache helpers ────────────────────────────────────────────── + + async fn get_services_cached(&self) -> Result { + if let Some(cached) = self.service_cache.get().await { + return Ok(cached); + } + let services = self.client.list_services().await.map_err(|e| ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: format!("Failed to fetch services: {e:#}").into(), + data: None, + })?; + self.service_cache.set(services.clone()).await; + Ok(services) + } + + /// Invalidate the service cache and notify the MCP client that the tool + /// list has changed. Called after deploy/delete operations. + async fn notify_tools_changed(&self) { + self.service_cache.invalidate().await; + if let Some(peer) = self.peer.try_read().ok().and_then(|g| g.clone()) { + if let Err(e) = peer.notify_tool_list_changed().await { + tracing::warn!("Failed to send tools/list_changed notification: {e}"); + } + } + } + // ── Tool implementations ─────────────────────────────────────────────── async fn tool_get_node_info(&self) -> Result { @@ -334,6 +539,21 @@ impl WavsMcpServer { Ok(m) => m, Err(e) => return err(format!("Invalid service_manager_json: {e}")), }; + let nav_directive = match &manager { + wavs_types::ServiceManager::Evm { chain, address } => { + format!( + "\n\n⚡ ACTION REQUIRED: If you have the ui_navigate tool, call it NOW with path=\"/services/{chain}/{address}\" \ + to show the user their new service. Do this BEFORE any other step." + ) + } + wavs_types::ServiceManager::Cosmos { chain, address } => { + format!( + "\n\n⚡ ACTION REQUIRED: If you have the ui_navigate tool, call it NOW with path=\"/services/{chain}/{address}\" \ + to show the user their new service. Do this BEFORE any other step." + ) + } + }; + match self.client.deploy_service(manager.clone()).await { Ok(v) if v.is_null() => { let signer_info = match self.client.get_service_signer(manager).await { @@ -345,9 +565,15 @@ impl WavsMcpServer { } Err(_) => String::new(), }; - ok(format!("Service registered successfully.{signer_info}")) + self.notify_tools_changed().await; + ok(format!( + "Service registered successfully.{nav_directive}{signer_info}" + )) + } + Ok(v) => { + self.notify_tools_changed().await; + ok(serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())) } - Ok(v) => ok(serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())), Err(e) => err(format!("Failed to deploy service: {e:#}")), } } @@ -362,7 +588,10 @@ impl WavsMcpServer { Err(e) => return err(format!("Invalid service_manager_json: {e}")), }; match self.client.delete_service(manager).await { - Ok(()) => ok("Service deleted successfully"), + Ok(()) => { + self.notify_tools_changed().await; + ok("Service deleted successfully") + } Err(e) => err(format!("Failed to delete service: {e:#}")), } } @@ -443,26 +672,129 @@ impl WavsMcpServer { args: Option>, ) -> Result { let p: DeployDevServiceParams = parse_args(args)?; - let manager: Option = - serde_json::from_str::(&p.service_json) - .ok() - .and_then(|v| serde_json::from_value(v.get("manager")?.clone()).ok()); - match self.client.deploy_dev_service(&p.service_json).await { + + // Parse the service JSON + let mut service_value: serde_json::Value = + serde_json::from_str(&p.service_json).map_err(|e| ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: format!("Invalid service JSON: {e}").into(), + data: None, + })?; + + // For dev services: replace placeholder manager addresses with unique random ones. + // This prevents "already registered" errors when agents copy example addresses verbatim. + let manager_replaced = if let Some(addr) = service_value + .pointer("/manager/evm/address") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + { + if is_placeholder_address(&addr) { + let random_addr = format!("0x{}", random_hex(40)); + service_value["manager"]["evm"]["address"] = serde_json::Value::String(random_addr); + true + } else { + false + } + } else { + false + }; + + let service_json = serde_json::to_string(&service_value).unwrap(); + + let manager: Option = service_value + .get("manager") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + // Extract workflow IDs for the summary + let workflow_ids: Vec = service_value + .get("workflows") + .and_then(|v| v.as_object()) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + + match self.client.deploy_dev_service(&service_json).await { Ok(hash) => { + // Compute the service_id from the ServiceManager + let service_id_info = if let Some(ref mgr) = manager { + let sid = wavs_types::ServiceId::from(mgr); + format!( + "\nservice_id: {sid} ← use this for wavs_simulate_trigger and wavs_query_component_logs" + ) + } else { + String::new() + }; + + let manager_info = if manager_replaced { + let addr = service_value["manager"]["evm"]["address"] + .as_str() + .unwrap_or("unknown"); + format!( + "\nmanager_address: {addr} (placeholder was replaced with unique address)" + ) + } else { + String::new() + }; + + let workflow_info = if !workflow_ids.is_empty() { + format!( + "\nworkflow_id(s): {}", + workflow_ids + .iter() + .map(|w| format!("\"{}\"", w)) + .collect::>() + .join(", ") + ) + } else { + String::new() + }; + let signer_info = if let Some(mgr) = manager { match self.client.get_service_signer(mgr).await { Ok(wavs_types::SignerResponse::Secp256k1 { hd_index, evm_address, - }) => { - format!("\nSigning key: HD index {hd_index} ({evm_address})") - } + }) => format!("\nsigning_key: HD index {hd_index} ({evm_address})"), Err(_) => String::new(), } } else { String::new() }; - ok(format!("Service registered.\nHash: {hash}{signer_info}")) + + // Build the ui_navigate action directive + let nav_action = service_value + .pointer("/manager/evm") + .and_then(|evm| { + let chain = evm.get("chain")?.as_str()?; + let addr = evm.get("address")?.as_str()?; + Some(format!("/services/{chain}/{addr}")) + }) + .or_else(|| { + service_value.pointer("/manager/cosmos").and_then(|cosmos| { + let chain = cosmos.get("chain")?.as_str()?; + let addr = cosmos.get("address")?.as_str()?; + Some(format!("/services/{chain}/{addr}")) + }) + }); + + let nav_directive = if let Some(path) = nav_action { + format!( + "\n\n⚡ ACTION REQUIRED: If you have the ui_navigate tool, call it NOW with path=\"{path}\" \ + to show the user their new service. Do this BEFORE simulate_trigger or any other step." + ) + } else { + String::new() + }; + + self.notify_tools_changed().await; + ok(format!( + "✅ Service deployed successfully.\ + {nav_directive}\n\n\ + deploy_hash: {hash}\ + {service_id_info}\ + {manager_info}\ + {workflow_info}\ + {signer_info}" + )) } Err(e) => err(format!("Failed to deploy dev service: {e:#}")), } @@ -742,11 +1074,25 @@ impl WavsMcpServer { args: Option>, ) -> Result { let p: ScaffoldComponentParams = parse_args(args)?; - ok(scaffold::scaffold_component( - &p.name, - &p.trigger_type, - p.description.as_deref(), - )) + if let Some(dir) = &p.dir { + // Write files to disk + match scaffold::scaffold_component_to_disk( + &p.name, + &p.trigger_type, + dir, + p.description.as_deref(), + ) { + Ok(summary) => ok(summary), + Err(e) => err(format!("Failed to scaffold component: {e}")), + } + } else { + // Return file contents as text + ok(scaffold::scaffold_component_text( + &p.name, + &p.trigger_type, + p.description.as_deref(), + )) + } } async fn tool_build_component( @@ -756,8 +1102,29 @@ impl WavsMcpServer { let p: BuildComponentParams = parse_args(args)?; let release = p.release.unwrap_or(true); + // Detect standalone vs workspace project. + // Standalone projects have a local `wit/` directory and no `[package.metadata.component]` + // with `package = "component:..."` that cargo-component uses. + // For standalone, use `cargo build --target wasm32-wasip2`. + // For workspace, use `cargo component build`. + let dir_path = std::path::Path::new(&p.dir); + let has_local_wit = dir_path.join("wit").is_dir(); + let cargo_toml_path = dir_path.join("Cargo.toml"); + let has_component_metadata = std::fs::read_to_string(&cargo_toml_path) + .map(|s| s.contains("[package.metadata.component]")) + .unwrap_or(false); + + // Use standalone build (wasm32-wasip2) when: + // - Project has local wit/ directory AND no component metadata, OR + // - Project has local wit/ directory AND is not in a cargo workspace + let use_standalone = has_local_wit && !has_component_metadata; + let mut cmd = tokio::process::Command::new("cargo"); - cmd.arg("component").arg("build"); + if use_standalone { + cmd.arg("build").arg("--target").arg("wasm32-wasip2"); + } else { + cmd.arg("component").arg("build"); + } if release { cmd.arg("--release"); } @@ -765,38 +1132,178 @@ impl WavsMcpServer { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + let build_cmd_str = if use_standalone { + "cargo build --target wasm32-wasip2" + } else { + "cargo component build" + }; + let output = match cmd.output().await { Ok(o) => o, - Err(e) => return err(format!("Failed to run `cargo component build`: {e:#}")), + Err(e) => return err(format!("Failed to run `{build_cmd_str}`: {e:#}")), }; let mut result = format!( - "Exit code: {}\n\nstdout:\n{}\n\nstderr:\n{}", + "Build command: {build_cmd_str}{release_flag}\nExit code: {}\n\nstdout:\n{}\n\nstderr:\n{}", output.status.code().unwrap_or(-1), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), + release_flag = if release { " --release" } else { "" }, ); if output.status.success() { // Scan for output .wasm files so callers can pass the path directly to wavs_upload_component. - let wasm_dir = std::path::Path::new(&p.dir).join("target/wasm32-wasip1/release"); - if let Ok(entries) = std::fs::read_dir(&wasm_dir) { - let mut wasm_files: Vec = entries - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("wasm")) - .filter_map(|p| p.to_str().map(|s| s.to_owned())) - .collect(); - wasm_files.sort(); - if !wasm_files.is_empty() { - result.push_str("\n\nOutput WASM files:"); - for f in &wasm_files { - result.push_str(&format!("\n {f}")); + // Check both wasip1 (cargo component) and wasip2 (standalone) output dirs. + for target_dir in &[ + "target/wasm32-wasip1/release", + "target/wasm32-wasip2/release", + ] { + let wasm_dir = dir_path.join(target_dir); + if let Ok(entries) = std::fs::read_dir(&wasm_dir) { + let mut wasm_files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("wasm")) + .filter_map(|p| p.to_str().map(|s| s.to_owned())) + .collect(); + wasm_files.sort(); + if !wasm_files.is_empty() { + result.push_str("\n\nOutput WASM files:"); + for f in &wasm_files { + result.push_str(&format!("\n {f}")); + } } } } ok(result) } else { + // Enhance error messages for common issues + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("failed to create a target world") + || stderr.contains("package not found") + { + result.push_str( + "\n\n💡 Hint: WIT interface files may be missing or incomplete. \ + For standalone projects, ensure all wit/deps/*/package.wit files are present. \ + Re-run wavs_scaffold_component to get the complete file list.", + ); + } + if stderr.contains("no export") && stderr.contains("run") { + result.push_str("\n\n💡 Hint: Component doesn't export the required 'run' function. \ + Ensure the `export!()` macro (standalone) or `export_layer_trigger_world!()` macro (workspace) \ + is present, and that `impl Guest for Component` is correct."); + } + err(result) + } + } + + async fn tool_validate_component( + &self, + args: Option>, + ) -> Result { + let p: ValidateComponentParams = parse_args(args)?; + let wasm_path = std::path::Path::new(&p.wasm_path); + + if !wasm_path.exists() { + return err(format!("File not found: {}", p.wasm_path)); + } + + // Use wasm-tools to inspect the component + let output = match tokio::process::Command::new("wasm-tools") + .args(["component", "wit"]) + .arg(&p.wasm_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await + { + Ok(o) => o, + Err(e) => { + return err(format!( + "Failed to run `wasm-tools component wit`: {e:#}\n\n\ + Install with: cargo install wasm-tools" + )) + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return err(format!( + "❌ Not a valid WASI component.\n\n\ + The file may be a core WebAssembly module (not a component).\n\ + - If using standalone build, ensure you built with: `cargo build --target wasm32-wasip2 --release`\n\ + - If using workspace build, ensure you used: `cargo component build --release`\n\n\ + wasm-tools error:\n{stderr}" + )); + } + + let wit_output = String::from_utf8_lossy(&output.stdout); + + // Check for the required export + let has_run_export = wit_output.contains("export run: func(trigger-action: trigger-action) -> result, string>"); + + // Check for key imports + let has_operator_input = wit_output.contains("wavs:operator/input"); + let has_operator_output = wit_output.contains("wavs:operator/output"); + let has_types = wit_output.contains("wavs:types/"); + + let mut issues: Vec = Vec::new(); + let mut info: Vec = Vec::new(); + + if !has_run_export { + issues.push( + "Missing `export run` function. Ensure the export macro is present:\n \ + - Standalone: `bindings::export!(Component with_types_in bindings);`\n \ + - Workspace: `export_layer_trigger_world!(Component);`\n \ + And that `impl Guest for Component` has the correct signature." + .to_string(), + ); + } else { + info.push("✅ Exports `run` function with correct signature".to_string()); + } + + if !has_operator_input || !has_operator_output { + issues.push( + "Missing wavs:operator imports. The WIT files may be incomplete or corrupted." + .to_string(), + ); + } else { + info.push("✅ Imports wavs:operator input/output interfaces".to_string()); + } + + if !has_types { + issues.push( + "Missing wavs:types imports. Ensure wavs-types WIT dep is present.".to_string(), + ); + } else { + info.push("✅ Imports wavs:types definitions".to_string()); + } + + // File size info + if let Ok(metadata) = std::fs::metadata(&p.wasm_path) { + let size_kb = metadata.len() / 1024; + info.push(format!("📦 Component size: {} KB", size_kb)); + } + + let mut result = String::new(); + if issues.is_empty() { + result.push_str("# ✅ Component Validation Passed\n\n"); + result.push_str(&format!("File: `{}`\n\n", p.wasm_path)); + for line in &info { + result.push_str(&format!("{line}\n")); + } + result.push_str("\nThe component is ready for upload with `wavs_upload_component`."); + ok(result) + } else { + result.push_str("# ❌ Component Validation Failed\n\n"); + result.push_str(&format!("File: `{}`\n\n", p.wasm_path)); + for line in &info { + result.push_str(&format!("{line}\n")); + } + result.push_str("\n## Issues\n\n"); + for issue in &issues { + result.push_str(&format!("- {issue}\n\n")); + } err(result) } } @@ -810,6 +1317,12 @@ Use this as a reference when calling wavs_save_service or wavs_deploy_dev_servic Raw 64-character hex string returned by wavs_upload_component. NO "sha256:" prefix. Example: f0b42a5171c9dcd75eac41c8ce2c4e7882d304c885266d8ac7b70af996b9a420 +### Manager address +The `manager` field uniquely identifies the service. +- For real deployments: use the actual on-chain ServiceManager contract address. +- For dev/testing (wavs_deploy_dev_service): use any placeholder (e.g. `0x1234...`). + The tool automatically replaces placeholder addresses with unique random ones to avoid collisions. + --- ### Manual trigger (fires only via wavs_simulate_trigger) @@ -817,7 +1330,7 @@ Example: f0b42a5171c9dcd75eac41c8ce2c4e7882d304c885266d8ac7b70af996b9a420 { "name": "my-service", "status": "active", - "manager": {"evm": {"chain": "evm:31337", "address": "0xServiceManagerAddress"}}, + "manager": {"evm": {"chain": "evm:31337", "address": "0x1234567890abcdef1234567890abcdef12345678"}}, "workflows": { "default": { "trigger": "manual", @@ -840,7 +1353,7 @@ Example: f0b42a5171c9dcd75eac41c8ce2c4e7882d304c885266d8ac7b70af996b9a420 { "name": "my-cron-service", "status": "active", - "manager": {"evm": {"chain": "evm:31337", "address": "0xServiceManagerAddress"}}, + "manager": {"evm": {"chain": "evm:31337", "address": "0x1234567890abcdef1234567890abcdef12345678"}}, "workflows": { "default": { "trigger": {"cron": {"schedule": "0 * * * * * *", "start_time": null, "end_time": null}}, @@ -946,31 +1459,51 @@ Note: trigger_json for simulate uses {"manual": null}, not the bare string "manu impl ServerHandler for WavsMcpServer { fn get_info(&self) -> ServerInfo { + let mut instructions = String::from( + "MCP server for the WAVS (WebAssembly-based Actively Validated Services) platform.\n\ + \n\ + Read tools (no auth needed): wavs_get_node_info, wavs_get_health, wavs_list_services, wavs_get_service\n\ + Write tools (need --token): wavs_deploy_service, wavs_delete_service\n\ + Dev tools (need dev endpoints): wavs_upload_component, wavs_save_service, wavs_simulate_trigger, wavs_deploy_dev_service, wavs_query_kv\n\ + Chain-write tools (need WAVS_MCP_CHAIN_CREDENTIAL on MCP server): wavs_set_service_uri, wavs_deploy_service_manager, wavs_deploy_poa_service_manager\n\ + Chain-write tools (also need WAVS_SIGNING_MNEMONIC): wavs_register_operator, wavs_deploy_and_register, wavs_get_signing_address\n\ + Node-read tools (need --token): wavs_get_service_signer\n\ + Local tools: wavs_get_service_schema, wavs_get_wit_interface, wavs_scaffold_component, wavs_build_component, wavs_validate_component", + ); + if self.exec_enabled { + instructions.push_str( + "\n\nExecution tools (--exec-enabled): wavs_exec_* tools are dynamically generated \ + for each deployed service workflow. Use trust_tier to select result_only, signed_result, \ + or on_chain execution mode.", + ); + } ServerInfo { server_info: Implementation { name: "wavs-mcp".into(), version: env!("CARGO_PKG_VERSION").into(), }, capabilities: ServerCapabilities { - tools: Some(Default::default()), + tools: Some(ToolsCapability { + list_changed: Some(true), + }), ..Default::default() }, - instructions: Some( - "MCP server for the WAVS (WebAssembly-based Actively Validated Services) platform.\n\ - \n\ - Read tools (no auth needed): wavs_get_node_info, wavs_get_health, wavs_list_services, wavs_get_service\n\ - Write tools (need --token): wavs_deploy_service, wavs_delete_service\n\ - Dev tools (need dev endpoints): wavs_upload_component, wavs_save_service, wavs_simulate_trigger, wavs_deploy_dev_service, wavs_query_kv\n\ - Chain-write tools (need WAVS_MCP_CHAIN_CREDENTIAL on MCP server): wavs_set_service_uri, wavs_deploy_service_manager, wavs_deploy_poa_service_manager\n\ - Chain-write tools (also need WAVS_SIGNING_MNEMONIC): wavs_register_operator, wavs_deploy_and_register, wavs_get_signing_address\n\ - Node-read tools (need --token): wavs_get_service_signer\n\ - Local tools: wavs_get_service_schema, wavs_get_wit_interface, wavs_scaffold_component, wavs_build_component" - .to_string(), - ), + instructions: Some(instructions), ..Default::default() } } + fn set_peer(&mut self, peer: Peer) { + let peer_store = self.peer.clone(); + tokio::spawn(async move { + *peer_store.write().await = Some(peer); + }); + } + + fn get_peer(&self) -> Option> { + self.peer.try_read().ok().and_then(|g| g.clone()) + } + async fn list_tools( &self, _req: PaginatedRequestParam, @@ -978,8 +1511,7 @@ impl ServerHandler for WavsMcpServer { ) -> Result { let empty = no_params(); - Ok(ListToolsResult { - tools: vec![ + let mut tools = vec![ // Read tools tool("wavs_get_node_info", "Get WAVS node information: service count, chain keys, aggregator config, P2P status", @@ -1095,13 +1627,20 @@ impl ServerHandler for WavsMcpServer { Tool { name: "wavs_simulate_trigger".into(), description: "Simulate a trigger against a deployed service. \ + The service_id parameter is the 64-char hex ID returned by wavs_deploy_dev_service \ + (labeled as `service_id`, NOT the `deploy_hash`). \ + The trigger_json and data_json must match the trigger type configured in the service. \ + Use wavs_get_service_schema for examples of trigger/data JSON formats. \ Requires dev endpoints enabled in wavs.toml.".into(), input_schema: schema_for_type::().into(), }, Tool { name: "wavs_deploy_dev_service".into(), description: "Register a service directly without an on-chain contract (dev/testing only). \ - Pass the full Service JSON. Handles the two-step save+register flow internally. \ + Pass the full Service JSON. Placeholder manager addresses (like 0x1234...) are \ + automatically replaced with unique random addresses to prevent collisions. \ + Returns the service_id (needed for wavs_simulate_trigger) and other details. \ + Handles the two-step save+register flow internally. \ Requires dev endpoints enabled in wavs.toml and --token. \ Call wavs_get_service_schema first to see a minimal valid example. \ Use this for local dev. For production with a real ServiceManager contract, \ @@ -1147,17 +1686,51 @@ impl ServerHandler for WavsMcpServer { empty.clone()), Tool { name: "wavs_scaffold_component".into(), - description: "Generate a ready-to-build WAVS WASM component scaffold (Cargo.toml + lib.rs). \ + description: "Create a complete, ready-to-build WAVS WASM component project. \ + If `dir` is provided, writes all files to disk at `{dir}/{name}/` (recommended). \ + If `dir` is omitted, returns file contents as text for manual creation. \ + Includes Cargo.toml, src/lib.rs, src/bindings.rs, and the full WIT interface directory. \ + The generated project is self-contained and builds with `cargo build --target wasm32-wasip2 --release`. \ + After scaffolding, customize src/lib.rs then use wavs_build_component to compile. \ Trigger types: evm_contract_event | cosmos_contract_event | block_interval | cron | manual".into(), input_schema: schema_for_type::().into(), }, Tool { name: "wavs_build_component".into(), - description: "Build a WAVS WASM component using `cargo component build`. \ - Returns full build output.".into(), + description: "Build a WAVS WASM component. \ + Auto-detects build mode: uses `cargo build --target wasm32-wasip2` for standalone projects \ + (with local wit/ directory) or `cargo component build` for workspace projects. \ + Returns full build output and output .wasm file paths.".into(), input_schema: schema_for_type::().into(), }, - ], + Tool { + name: "wavs_validate_component".into(), + description: "Validate a compiled .wasm component before uploading. \ + Checks that the file is a valid WASI component (not a core module), \ + exports the required `run` function with the correct signature, \ + and imports the expected WAVS interfaces. \ + Requires `wasm-tools` to be installed. \ + Run this after wavs_build_component and before wavs_upload_component.".into(), + input_schema: schema_for_type::().into(), + }, + ]; + + // Conditionally add dynamic exec tools for deployed services + if self.exec_enabled { + match self.get_services_cached().await { + Ok(services) => { + let exec_tools = exec::build_exec_tools(&services); + tools.extend(exec_tools); + } + Err(e) => { + tracing::warn!("Failed to build exec tools: {}", e.message); + // Continue with just management tools -- don't fail the whole list + } + } + } + + Ok(ListToolsResult { + tools, next_cursor: None, }) } @@ -1193,6 +1766,33 @@ impl ServerHandler for WavsMcpServer { "wavs_get_wit_interface" => self.tool_get_wit_interface().await, "wavs_scaffold_component" => self.tool_scaffold_component(args).await, "wavs_build_component" => self.tool_build_component(args).await, + "wavs_validate_component" => self.tool_validate_component(args).await, + name if name.starts_with("wavs_exec_") => { + if !self.exec_enabled { + return Err(ErrorData { + code: ErrorCode::INVALID_REQUEST, + message: "Execution tools are disabled. Restart the MCP server with --exec-enabled.".into(), + data: None, + }); + } + let services = self.get_services_cached().await?; + let signing_cred = self + .signing_mnemonic + .as_deref() + .and_then(|s| s.parse::().ok()); + let chain_cred = self + .mcp_chain_credential + .as_deref() + .and_then(|s| s.parse::().ok()); + let ctx = exec::ExecContext { + client: &self.client, + services_json: &services, + signing_mnemonic: signing_cred.as_ref(), + mcp_chain_credential: chain_cred.as_ref(), + pending_confirmations: Some(&self.pending_confirmations), + }; + exec::handle_exec_tool(&ctx, name, args).await + } name => Err(ErrorData { code: ErrorCode::METHOD_NOT_FOUND, message: format!("Unknown tool: {name}").into(), diff --git a/packages/wit-schema/Cargo.toml b/packages/wit-schema/Cargo.toml new file mode 100644 index 000000000..84edef1e9 --- /dev/null +++ b/packages/wit-schema/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "wit-schema" +description = "WIT-to-JSON-Schema conversion library for WAVS components" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +repository.workspace = true +license.workspace = true +publish = false + +[dependencies] +wasmtime = { workspace = true } +wavs-types = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +lru = { workspace = true } +wit-parser = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/packages/wit-schema/src/cache.rs b/packages/wit-schema/src/cache.rs new file mode 100644 index 000000000..b8a8d7ca8 --- /dev/null +++ b/packages/wit-schema/src/cache.rs @@ -0,0 +1,118 @@ +use std::num::NonZeroUsize; +use std::sync::Mutex; + +use lru::LruCache; +use serde_json::Value; +use wavs_types::ComponentDigest; + +const DEFAULT_CACHE_SIZE: usize = 32; + +/// LRU cache for generated schemas, keyed by component digest (SHA256). +/// +/// Thread-safe via Mutex, following the same pattern as `BaseEngine` in +/// `packages/engine/src/common/base_engine.rs`. +pub struct SchemaCache { + cache: Mutex>, +} + +impl SchemaCache { + /// Create a new cache with the given capacity. + /// If capacity is 0, falls back to DEFAULT_CACHE_SIZE. + pub fn new(capacity: usize) -> Self { + Self { + cache: Mutex::new(LruCache::new( + NonZeroUsize::new(capacity) + .unwrap_or(NonZeroUsize::new(DEFAULT_CACHE_SIZE).unwrap()), + )), + } + } + + /// Look up a cached schema by component digest. + /// Returns a clone of the cached value if found. + pub fn get(&self, digest: &ComponentDigest) -> Option { + self.cache.lock().unwrap().get(digest).cloned() + } + + /// Store a schema in the cache, keyed by component digest. + pub fn put(&self, digest: ComponentDigest, schema: Value) { + self.cache.lock().unwrap().put(digest, schema); + } +} + +impl Default for SchemaCache { + fn default() -> Self { + Self::new(DEFAULT_CACHE_SIZE) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_digest(data: &[u8]) -> ComponentDigest { + ComponentDigest::hash(data) + } + + #[test] + fn test_put_then_get_returns_same_value() { + let cache = SchemaCache::default(); + let digest = make_digest(b"test-component-bytes"); + let schema = json!({"world": "test", "exports": {}}); + + cache.put(digest.clone(), schema.clone()); + let result = cache.get(&digest); + + assert_eq!(result, Some(schema)); + } + + #[test] + fn test_get_missing_key_returns_none() { + let cache = SchemaCache::default(); + let digest = make_digest(b"nonexistent"); + + assert_eq!(cache.get(&digest), None); + } + + #[test] + fn test_cache_eviction_when_capacity_exceeded() { + let cache = SchemaCache::new(2); + + let d1 = make_digest(b"component-1"); + let d2 = make_digest(b"component-2"); + let d3 = make_digest(b"component-3"); + + cache.put(d1.clone(), json!({"id": 1})); + cache.put(d2.clone(), json!({"id": 2})); + // This should evict d1 + cache.put(d3.clone(), json!({"id": 3})); + + assert_eq!(cache.get(&d1), None, "d1 should have been evicted"); + assert_eq!(cache.get(&d2), Some(json!({"id": 2}))); + assert_eq!(cache.get(&d3), Some(json!({"id": 3}))); + } + + #[test] + fn test_default_creates_cache_with_capacity_32() { + let cache = SchemaCache::default(); + // We can verify by inserting 32 items and checking they're all still there + for i in 0..32 { + let digest = make_digest(format!("component-{}", i).as_bytes()); + cache.put(digest, json!({"id": i})); + } + // All 32 should be present + for i in 0..32 { + let digest = make_digest(format!("component-{}", i).as_bytes()); + assert!( + cache.get(&digest).is_some(), + "component-{} should be in cache", + i + ); + } + // Adding a 33rd should evict the first + let d33 = make_digest(b"component-32"); + cache.put(d33, json!({"id": 32})); + let d0 = make_digest(b"component-0"); + assert_eq!(cache.get(&d0), None, "component-0 should have been evicted"); + } +} diff --git a/packages/wit-schema/src/convert.rs b/packages/wit-schema/src/convert.rs new file mode 100644 index 000000000..a7a6a48ae --- /dev/null +++ b/packages/wit-schema/src/convert.rs @@ -0,0 +1,351 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde_json::{json, Value}; +use wasmtime::component::types::{self, Type}; + +/// Compute a structural fingerprint for a type, used for $defs deduplication (D-06). +/// Returns None for primitive types that don't need deduplication. +fn type_fingerprint(ty: &Type) -> Option { + match ty { + Type::Record(record) => { + let fields: Vec = record.fields().map(|f| f.name.to_string()).collect(); + Some(format!("record:{}", fields.join("|"))) + } + Type::Variant(variant) => { + let cases: Vec = variant.cases().map(|c| c.name.to_string()).collect(); + Some(format!("variant:{}", cases.join("|"))) + } + Type::Enum(enum_ty) => { + let names: Vec = enum_ty.names().map(|n| n.to_string()).collect(); + Some(format!("enum:{}", names.join("|"))) + } + Type::Flags(flags) => { + let names: Vec = flags.names().map(|n| n.to_string()).collect(); + Some(format!("flags:{}", names.join("|"))) + } + _ => None, + } +} + +/// Generate a def name from a fingerprint. +fn def_name_from_fingerprint(fingerprint: &str) -> String { + // Strip the type prefix and use field/case names + let parts: Vec<&str> = fingerprint.splitn(2, ':').collect(); + if parts.len() == 2 { + parts[1].replace('|', "_") + } else { + fingerprint.replace('|', "_") + } +} + +/// Convert a WIT type to its JSON Schema representation. +/// +/// `defs` accumulates shared type definitions for the `$defs` section. +/// `seen_types` tracks structural fingerprints for deduplication (D-06). +/// `param_name` is an optional hint for naming $defs entries. +pub fn type_to_schema( + ty: &Type, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + type_to_schema_inner(ty, defs, seen_types, None) +} + +/// Convert a WIT type to JSON Schema with an optional parameter name hint for $defs naming. +pub fn type_to_schema_named( + ty: &Type, + defs: &mut BTreeMap, + seen_types: &mut HashMap, + param_name: Option<&str>, +) -> Value { + type_to_schema_inner(ty, defs, seen_types, param_name) +} + +fn type_to_schema_inner( + ty: &Type, + defs: &mut BTreeMap, + seen_types: &mut HashMap, + param_name: Option<&str>, +) -> Value { + // Check for $defs deduplication on complex types (D-06) + if let Some(fingerprint) = type_fingerprint(ty) { + let count = seen_types.entry(fingerprint.clone()).or_insert(0); + *count += 1; + + if *count > 1 { + // This type has been seen before -- use or create a $ref + let def_name = if let Some(name) = param_name { + name.to_string() + } else { + def_name_from_fingerprint(&fingerprint) + }; + + if !defs.contains_key(&def_name) { + // First time moving to $defs -- generate the schema and store it + let schema = convert_type_direct(ty, defs, seen_types); + defs.insert(def_name.clone(), schema); + } + + return json!({"$ref": format!("#/$defs/{}", def_name)}); + } + } + + convert_type_direct(ty, defs, seen_types) +} + +/// Convert a type directly without deduplication checks (used internally). +fn convert_type_direct( + ty: &Type, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + match ty { + Type::Bool => json!({"type": "boolean"}), + Type::U8 | Type::U16 | Type::U32 => json!({"type": "integer", "minimum": 0}), + Type::S8 | Type::S16 | Type::S32 => json!({"type": "integer"}), + Type::U64 | Type::S64 => json!({"type": "integer"}), + Type::Float32 | Type::Float64 => json!({"type": "number"}), + Type::Char => json!({"type": "string", "maxLength": 1}), + Type::String => json!({"type": "string"}), + Type::List(list) => list_to_schema(list, defs, seen_types), + Type::Record(record) => record_to_schema(record, defs, seen_types), + Type::Variant(variant) => variant_to_schema(variant, defs, seen_types), + Type::Enum(enum_ty) => enum_to_schema(enum_ty), + Type::Option(opt) => option_to_schema(opt, defs, seen_types), + Type::Result(result) => result_to_schema(result, defs, seen_types), + Type::Tuple(tuple) => tuple_to_schema(tuple, defs, seen_types), + Type::Flags(flags) => flags_to_schema(flags), + // Resource types (Own, Borrow) and others -- not expected in WAVS components + _ => json!({}), + } +} + +/// Handle list types, with special case for list (D-03/Pitfall 4). +fn list_to_schema( + list: &types::List, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + // Special case: list represents bytes + if matches!(list.ty(), Type::U8) { + json!({"type": "string", "contentEncoding": "base64"}) + } else { + json!({ + "type": "array", + "items": type_to_schema_inner(&list.ty(), defs, seen_types, None) + }) + } +} + +/// Check if a record is the WAVS u128 type (D-03). +/// u128 is defined as: record u128 { value: tuple } +fn is_u128_record(record: &types::Record) -> bool { + let fields: Vec<_> = record.fields().collect(); + if fields.len() != 1 { + return false; + } + let field = &fields[0]; + if field.name != "value" { + return false; + } + if let Type::Tuple(tuple) = &field.ty { + let types: Vec<_> = tuple.types().collect(); + types.len() == 2 && matches!(types[0], Type::U64) && matches!(types[1], Type::U64) + } else { + false + } +} + +/// Convert a record type to JSON Schema (D-01). +/// Checks for u128 special case first (D-03). +fn record_to_schema( + record: &types::Record, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + // u128 special case (D-03) + if is_u128_record(record) { + return json!({ + "type": "string", + "pattern": "^[0-9]+$", + "description": "128-bit unsigned integer" + }); + } + + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for field in record.fields() { + properties.insert( + field.name.to_string(), + type_to_schema_inner(&field.ty, defs, seen_types, Some(field.name)), + ); + required.push(json!(field.name)); + } + + json!({ + "type": "object", + "properties": Value::Object(properties), + "required": required, + "additionalProperties": false + }) +} + +/// Convert a variant type to JSON Schema with externally tagged representation (D-01). +fn variant_to_schema( + variant: &types::Variant, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + let mut one_of = Vec::new(); + + for case in variant.cases() { + let payload_schema = if let Some(ref payload_ty) = case.ty { + type_to_schema_inner(payload_ty, defs, seen_types, Some(case.name)) + } else { + // No-payload variant case -- value is an empty object + json!({"type": "object", "maxProperties": 0}) + }; + + let mut props = serde_json::Map::new(); + props.insert(case.name.to_string(), payload_schema); + + one_of.push(json!({ + "type": "object", + "properties": Value::Object(props), + "required": [case.name], + "additionalProperties": false + })); + } + + json!({"oneOf": one_of}) +} + +/// Convert an enum type to JSON Schema (D-02). +fn enum_to_schema(enum_ty: &types::Enum) -> Value { + let names: Vec = enum_ty.names().map(|n| json!(n)).collect(); + json!({"type": "string", "enum": names}) +} + +/// Convert an option type to JSON Schema (nullable). +fn option_to_schema( + opt: &types::OptionType, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + json!({ + "anyOf": [ + type_to_schema_inner(&opt.ty(), defs, seen_types, None), + {"type": "null"} + ] + }) +} + +/// Convert a result type to JSON Schema (full representation for inputs). +fn result_to_schema( + result: &types::ResultType, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + let ok_schema = result + .ok() + .map(|ty| type_to_schema_inner(&ty, defs, seen_types, None)) + .unwrap_or_else(|| json!({"type": "object", "maxProperties": 0})); + let err_schema = result + .err() + .map(|ty| type_to_schema_inner(&ty, defs, seen_types, None)) + .unwrap_or_else(|| json!({"type": "object", "maxProperties": 0})); + + let mut ok_props = serde_json::Map::new(); + ok_props.insert("ok".to_string(), ok_schema); + + let mut err_props = serde_json::Map::new(); + err_props.insert("err".to_string(), err_schema); + + json!({ + "oneOf": [ + { + "type": "object", + "properties": Value::Object(ok_props), + "required": ["ok"], + "additionalProperties": false + }, + { + "type": "object", + "properties": Value::Object(err_props), + "required": ["err"], + "additionalProperties": false + } + ] + }) +} + +/// Convert a result type for output schemas, simplifying result cases. +/// +/// When the error type is `string`, returns just the ok type schema with a description +/// noting the error possibility. Otherwise returns the full oneOf representation. +pub fn result_to_output_schema( + result: &types::ResultType, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + // Check if the error type is string (common WAVS pattern) + let err_is_string = result + .err() + .map(|ty| matches!(ty, Type::String)) + .unwrap_or(false); + + if err_is_string { + // Simplify: return the ok type as the primary schema + if let Some(ok_ty) = result.ok() { + let mut schema = type_to_schema_inner(&ok_ty, defs, seen_types, None); + // Add description noting the error type + if let Some(obj) = schema.as_object_mut() { + obj.insert( + "description".to_string(), + json!("On error, returns a string error message"), + ); + } + schema + } else { + // result<_, string> -- no ok type + json!({ + "type": "object", + "maxProperties": 0, + "description": "On error, returns a string error message" + }) + } + } else { + // Full representation for non-string errors + result_to_schema(result, defs, seen_types) + } +} + +/// Convert a tuple type to JSON Schema. +fn tuple_to_schema( + tuple: &types::Tuple, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + let items: Vec = tuple + .types() + .map(|ty| type_to_schema_inner(&ty, defs, seen_types, None)) + .collect(); + let len = items.len(); + json!({ + "type": "array", + "prefixItems": items, + "minItems": len, + "maxItems": len + }) +} + +/// Convert a flags type to JSON Schema. +fn flags_to_schema(flags: &types::Flags) -> Value { + let names: Vec = flags.names().map(|n| json!(n)).collect(); + json!({ + "type": "array", + "items": {"type": "string", "enum": names}, + "uniqueItems": true + }) +} diff --git a/packages/wit-schema/src/docs.rs b/packages/wit-schema/src/docs.rs new file mode 100644 index 000000000..64bd434a0 --- /dev/null +++ b/packages/wit-schema/src/docs.rs @@ -0,0 +1,247 @@ +use std::path::Path; + +use anyhow::Result; +use serde_json::Value; + +/// Enrich a generated schema with doc comments extracted from WIT source files. +/// +/// Walks the parsed WIT package, matches function and type names to schema entries, +/// and adds "description" fields where doc comments exist. +/// +/// Per D-07: If parsing fails or no docs found, logs a warning and returns Ok(()). +/// Doc comment enrichment never fails the schema generation. +pub fn enrich_with_docs(schema: &mut Value, wit_path: &Path) -> Result<()> { + let mut resolve = wit_parser::Resolve::new(); + + // Try to parse the WIT source. Use push_dir for directories, push_file for single files. + let package_id = if wit_path.is_dir() { + match resolve.push_dir(wit_path) { + Ok((pkg_id, _source_map)) => pkg_id, + Err(e) => { + tracing::warn!( + path = %wit_path.display(), + error = %e, + "Failed to parse WIT directory for doc enrichment, skipping" + ); + return Ok(()); + } + } + } else { + match resolve.push_file(wit_path) { + Ok(pkg_id) => pkg_id, + Err(e) => { + tracing::warn!( + path = %wit_path.display(), + error = %e, + "Failed to parse WIT file for doc enrichment, skipping" + ); + return Ok(()); + } + } + }; + + let package = &resolve.packages[package_id]; + + // Enrich exported function descriptions from worlds + for world_id in package.worlds.values() { + let world = &resolve.worlds[*world_id]; + for (key, item) in &world.exports { + match item { + wit_parser::WorldItem::Function(func) => { + if let Some(ref doc_contents) = func.docs.contents { + let func_name = match key { + wit_parser::WorldKey::Name(n) => n.clone(), + wit_parser::WorldKey::Interface(_) => continue, + }; + // Look for the function in schema exports + if let Some(export) = schema + .get_mut("exports") + .and_then(|e| e.get_mut(&func_name)) + { + if let Some(obj) = export.as_object_mut() { + obj.insert( + "description".to_string(), + Value::String(doc_contents.trim().to_string()), + ); + } + } + } + } + wit_parser::WorldItem::Interface { id, .. } => { + // Check functions inside exported interfaces + let iface = &resolve.interfaces[*id]; + for (func_name, func) in &iface.functions { + if let Some(ref doc_contents) = func.docs.contents { + // Try both bare name and interface-qualified name + let iface_name = iface + .name + .as_ref() + .map(|n| format!("{}/{}", n, func_name)) + .unwrap_or_else(|| func_name.clone()); + + if let Some(exports) = schema.get_mut("exports") { + // Try interface-qualified name first + if let Some(export) = exports.get_mut(&iface_name) { + if let Some(obj) = export.as_object_mut() { + obj.insert( + "description".to_string(), + Value::String(doc_contents.trim().to_string()), + ); + } + } + // Also try bare function name + if let Some(export) = exports.get_mut(func_name) { + if let Some(obj) = export.as_object_mut() { + obj.insert( + "description".to_string(), + Value::String(doc_contents.trim().to_string()), + ); + } + } + } + } + } + } + _ => {} + } + } + } + + // Enrich type descriptions in $defs + for (_type_id, typedef) in resolve.types.iter() { + if let Some(ref doc_contents) = typedef.docs.contents { + if let Some(ref name) = typedef.name { + // Try to find the type in $defs by name + if let Some(defs) = schema.get_mut("$defs") { + if let Some(def) = defs.get_mut(name) { + if let Some(obj) = def.as_object_mut() { + obj.insert( + "description".to_string(), + Value::String(doc_contents.trim().to_string()), + ); + } + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::Write; + + #[test] + fn test_enrich_with_doc_comments_from_fixture() { + let wit_content = r#"package test:example; + +interface types { + /// A greeting message + record greeting { + message: string, + } +} + +world test-world { + /// Say hello to someone + export hello: func(name: string) -> string; +} +"#; + + // Write fixture to temp file + let dir = tempfile::tempdir().unwrap(); + let wit_file = dir.path().join("test.wit"); + let mut f = std::fs::File::create(&wit_file).unwrap(); + f.write_all(wit_content.as_bytes()).unwrap(); + + // Build a mock schema that matches the fixture + let mut schema = json!({ + "world": "test-world", + "exports": { + "hello": { + "inputSchema": {"type": "string"}, + "outputSchema": {"type": "string"} + } + }, + "$defs": { + "greeting": { + "type": "object", + "properties": { + "message": {"type": "string"} + } + } + } + }); + + enrich_with_docs(&mut schema, &wit_file).unwrap(); + + // Check function description was added + let hello = schema.get("exports").unwrap().get("hello").unwrap(); + assert_eq!( + hello.get("description").and_then(|d| d.as_str()), + Some("Say hello to someone"), + "function doc comment should be added" + ); + + // Check type description was added + let greeting = schema.get("$defs").unwrap().get("greeting").unwrap(); + assert_eq!( + greeting.get("description").and_then(|d| d.as_str()), + Some("A greeting message"), + "type doc comment should be added" + ); + } + + #[test] + fn test_enrich_with_nonexistent_path_does_not_error() { + let mut schema = json!({ + "world": "test", + "exports": {}, + "$defs": {} + }); + + let result = enrich_with_docs(&mut schema, Path::new("/nonexistent/path/test.wit")); + assert!( + result.is_ok(), + "enriching with nonexistent path should not error" + ); + } + + #[test] + fn test_enrich_with_no_doc_comments_leaves_schema_unchanged() { + let wit_content = r#"package test:nodocs; + +world test-world { + export greet: func(name: string) -> string; +} +"#; + + let dir = tempfile::tempdir().unwrap(); + let wit_file = dir.path().join("nodocs.wit"); + let mut f = std::fs::File::create(&wit_file).unwrap(); + f.write_all(wit_content.as_bytes()).unwrap(); + + let mut schema = json!({ + "world": "test-world", + "exports": { + "greet": { + "inputSchema": {"type": "string"}, + "outputSchema": {"type": "string"} + } + }, + "$defs": {} + }); + + let schema_before = schema.clone(); + enrich_with_docs(&mut schema, &wit_file).unwrap(); + + assert_eq!( + schema, schema_before, + "schema without doc comments should remain unchanged" + ); + } +} diff --git a/packages/wit-schema/src/lib.rs b/packages/wit-schema/src/lib.rs new file mode 100644 index 000000000..c3184b769 --- /dev/null +++ b/packages/wit-schema/src/lib.rs @@ -0,0 +1,499 @@ +pub mod cache; +pub mod convert; +pub mod docs; +pub mod traverse; +pub mod types; + +pub use cache::SchemaCache; +pub use types::SchemaOptions; + +use std::collections::{BTreeMap, HashMap}; + +use serde_json::{json, Value}; +use wasmtime::component::types::Type; + +/// Generate a JSON Schema describing the exported functions of a WASM component. +/// +/// This is the primary public API. It introspects the component's type information +/// (without instantiating it) and produces a JSON Schema document with the structure +/// specified by D-04: +/// ```json +/// { +/// "world": "", +/// "exports": { +/// "func-name": { +/// "inputSchema": { ... }, +/// "outputSchema": { ... } +/// } +/// }, +/// "$defs": { ... } +/// } +/// ``` +/// +/// Only exported functions are included (D-05). Imported functions (WASI, host, etc.) +/// are excluded. +pub fn generate_schema( + engine: &wasmtime::Engine, + component: &wasmtime::component::Component, + _options: &SchemaOptions, +) -> anyhow::Result { + let component_type = component.component_type(); + let exports = traverse::gather_exports(&component_type, engine); + + let mut defs: BTreeMap = BTreeMap::new(); + let mut seen_types: HashMap = HashMap::new(); + let mut export_schemas = serde_json::Map::new(); + + // First pass: generate schemas for all exports to discover shared types + // We need two passes for proper $defs deduplication: + // 1. First pass discovers all types and which are shared + // 2. Second pass generates final schemas with $ref pointers + + // Collect type fingerprints across all exports to pre-populate seen_types + for (_name, func) in &exports { + for (_param_name, param_ty) in func.params() { + count_type_occurrences(¶m_ty, &mut seen_types); + } + for result_ty in func.results() { + count_type_occurrences(&result_ty, &mut seen_types); + } + } + + // Reset counts but keep fingerprints that appeared more than once + let shared_fingerprints: HashMap = seen_types + .iter() + .filter(|(_, count)| **count > 1) + .map(|(fp, _)| (fp.clone(), 0)) + .collect(); + seen_types = shared_fingerprints; + + // Second pass: generate actual schemas, using $ref for shared types + for (name, func) in &exports { + let input_schema = build_input_schema(func, &mut defs, &mut seen_types); + let output_schema = build_output_schema(func, &mut defs, &mut seen_types); + + let mut entry = serde_json::Map::new(); + entry.insert("inputSchema".to_string(), input_schema); + entry.insert("outputSchema".to_string(), output_schema); + + export_schemas.insert(name.clone(), Value::Object(entry)); + } + + // Assemble top-level schema per D-04 + let schema = json!({ + "world": "unknown", + "exports": Value::Object(export_schemas), + "$defs": defs + }); + + Ok(schema) +} + +/// Generate schema with caching and optional doc enrichment. +/// +/// Wraps `generate_schema` with: +/// 1. Digest-based cache lookup (skips regeneration for known components) +/// 2. Optional WIT source doc comment enrichment (D-07) +/// 3. Cache storage of the result +pub fn generate_schema_cached( + engine: &wasmtime::Engine, + component: &wasmtime::component::Component, + wasm_bytes: &[u8], + options: &SchemaOptions, + cache: &SchemaCache, +) -> anyhow::Result { + let digest = wavs_types::ComponentDigest::hash(wasm_bytes); + + // Check cache first + if let Some(cached) = cache.get(&digest) { + tracing::debug!("Schema cache hit for {}", digest); + return Ok(cached); + } + + // Generate schema + let mut schema = generate_schema(engine, component, options)?; + + // Optionally enrich with doc comments from WIT source + if let Some(ref wit_path) = options.wit_path { + docs::enrich_with_docs(&mut schema, wit_path)?; + } + + // Store in cache + cache.put(digest, schema.clone()); + + Ok(schema) +} + +/// Count type occurrences for deduplication discovery (first pass). +fn count_type_occurrences(ty: &Type, seen_types: &mut HashMap) { + if let Some(fingerprint) = type_fingerprint_for_counting(ty) { + *seen_types.entry(fingerprint).or_insert(0) += 1; + } + + // Recurse into complex types + match ty { + Type::Record(record) => { + for field in record.fields() { + count_type_occurrences(&field.ty, seen_types); + } + } + Type::Variant(variant) => { + for case in variant.cases() { + if let Some(ref payload_ty) = case.ty { + count_type_occurrences(payload_ty, seen_types); + } + } + } + Type::List(list) => { + count_type_occurrences(&list.ty(), seen_types); + } + Type::Option(opt) => { + count_type_occurrences(&opt.ty(), seen_types); + } + Type::Result(result) => { + if let Some(ok) = result.ok() { + count_type_occurrences(&ok, seen_types); + } + if let Some(err) = result.err() { + count_type_occurrences(&err, seen_types); + } + } + Type::Tuple(tuple) => { + for item_ty in tuple.types() { + count_type_occurrences(&item_ty, seen_types); + } + } + _ => {} + } +} + +/// Same as convert module's fingerprint but accessible here for counting. +fn type_fingerprint_for_counting(ty: &Type) -> Option { + match ty { + Type::Record(record) => { + let fields: Vec = record.fields().map(|f| f.name.to_string()).collect(); + Some(format!("record:{}", fields.join("|"))) + } + Type::Variant(variant) => { + let cases: Vec = variant.cases().map(|c| c.name.to_string()).collect(); + Some(format!("variant:{}", cases.join("|"))) + } + Type::Enum(enum_ty) => { + let names: Vec = enum_ty.names().map(|n| n.to_string()).collect(); + Some(format!("enum:{}", names.join("|"))) + } + Type::Flags(flags) => { + let names: Vec = flags.names().map(|n| n.to_string()).collect(); + Some(format!("flags:{}", names.join("|"))) + } + _ => None, + } +} + +/// Build the inputSchema for a function. +fn build_input_schema( + func: &wasmtime::component::types::ComponentFunc, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + let params: Vec<_> = func.params().collect(); + + match params.len() { + 0 => json!({"type": "object", "properties": {}, "additionalProperties": false}), + 1 => { + let (name, ty) = ¶ms[0]; + convert::type_to_schema_named(ty, defs, seen_types, Some(name)) + } + _ => { + // Multiple params -- wrap in an object + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + for (name, ty) in ¶ms { + properties.insert( + name.to_string(), + convert::type_to_schema_named(ty, defs, seen_types, Some(name)), + ); + required.push(json!(name)); + } + json!({ + "type": "object", + "properties": Value::Object(properties), + "required": required, + "additionalProperties": false + }) + } + } +} + +/// Build the outputSchema for a function. +fn build_output_schema( + func: &wasmtime::component::types::ComponentFunc, + defs: &mut BTreeMap, + seen_types: &mut HashMap, +) -> Value { + let results: Vec<_> = func.results().collect(); + + match results.len() { + 0 => json!({"type": "null"}), + 1 => { + let ty = &results[0]; + // Use result_to_output_schema for result types (simplifies result) + if let Type::Result(ref result_ty) = ty { + convert::result_to_output_schema(result_ty, defs, seen_types) + } else { + convert::type_to_schema(ty, defs, seen_types) + } + } + _ => { + // Multiple results -- create a tuple schema + let items: Vec = results + .iter() + .map(|ty| convert::type_to_schema(ty, defs, seen_types)) + .collect(); + let len = items.len(); + json!({ + "type": "array", + "prefixItems": items, + "minItems": len, + "maxItems": len + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + wasmtime::Engine::new(&config).expect("failed to create engine") + } + + fn load_component(engine: &wasmtime::Engine, name: &str) -> wasmtime::component::Component { + let path = format!( + "{}/examples/build/components/{}.wasm", + env!("CARGO_MANIFEST_DIR").replace("/packages/wit-schema", ""), + name + ); + let bytes = + std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {}: {}", path, e)); + wasmtime::component::Component::new(engine, &bytes) + .unwrap_or_else(|e| panic!("failed to load component {}: {}", name, e)) + } + + #[test] + fn test_echo_data_schema_has_exports_with_run() { + let engine = make_engine(); + let component = load_component(&engine, "echo_data"); + let schema = generate_schema(&engine, &component, &SchemaOptions::default()).unwrap(); + + println!( + "echo_data schema:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + + assert!( + schema.get("exports").is_some(), + "schema must have 'exports' key" + ); + let exports = schema.get("exports").unwrap(); + let has_run = exports + .as_object() + .unwrap() + .keys() + .any(|k| k.contains("run")); + assert!( + has_run, + "exports must contain 'run' function, got: {:?}", + exports + ); + + let run_export = exports + .as_object() + .unwrap() + .iter() + .find(|(k, _)| k.contains("run")) + .map(|(_, v)| v) + .unwrap(); + assert!( + run_export.get("inputSchema").is_some(), + "run must have inputSchema" + ); + assert!( + run_export.get("outputSchema").is_some(), + "run must have outputSchema" + ); + } + + #[test] + fn test_top_level_structure_d04() { + let engine = make_engine(); + let component = load_component(&engine, "echo_data"); + let schema = generate_schema(&engine, &component, &SchemaOptions::default()).unwrap(); + + assert!( + schema.get("world").is_some(), + "schema must have 'world' key" + ); + assert!( + schema.get("exports").is_some(), + "schema must have 'exports' key" + ); + assert!( + schema.get("$defs").is_some(), + "schema must have '$defs' key" + ); + } + + #[test] + fn test_aggregator_multiple_exports() { + let engine = make_engine(); + let component = load_component(&engine, "timer_aggregator"); + let schema = generate_schema(&engine, &component, &SchemaOptions::default()).unwrap(); + + println!( + "timer_aggregator schema:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + + let exports = schema.get("exports").unwrap().as_object().unwrap(); + // Aggregator world has 3 exports: process-input, handle-timer-callback, handle-submit-callback + assert!( + exports.len() >= 3, + "aggregator should have at least 3 exports, got {}: {:?}", + exports.len(), + exports.keys().collect::>() + ); + } + + #[test] + fn test_square_simple_types() { + let engine = make_engine(); + let component = load_component(&engine, "square"); + let schema = generate_schema(&engine, &component, &SchemaOptions::default()).unwrap(); + + println!( + "square schema:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + + assert!(schema.get("exports").is_some(), "schema must have exports"); + } + + #[test] + fn test_exports_only_d05() { + let engine = make_engine(); + let component = load_component(&engine, "echo_data"); + let schema = generate_schema(&engine, &component, &SchemaOptions::default()).unwrap(); + + let exports = schema.get("exports").unwrap().as_object().unwrap(); + for (name, _) in exports { + assert!( + !name.contains("get-evm-chain-config") + && !name.contains("config-var") + && !name.contains("wasi:"), + "found imported function in exports: {}", + name + ); + } + } + + #[test] + fn test_defs_deduplication_d06() { + let engine = make_engine(); + let component = load_component(&engine, "timer_aggregator"); + let schema = generate_schema(&engine, &component, &SchemaOptions::default()).unwrap(); + + println!( + "timer_aggregator $defs:\n{}", + serde_json::to_string_pretty(schema.get("$defs").unwrap()).unwrap() + ); + + let defs = schema.get("$defs").unwrap().as_object().unwrap(); + assert!( + !defs.is_empty(), + "aggregator schema should have shared types in $defs" + ); + + let exports_str = serde_json::to_string(schema.get("exports").unwrap()).unwrap(); + assert!( + exports_str.contains("$ref"), + "exports should contain $ref pointers to $defs" + ); + } + + #[test] + fn test_generate_schema_cached_returns_cached_on_second_call() { + let engine = make_engine(); + let path = format!( + "{}/examples/build/components/echo_data.wasm", + env!("CARGO_MANIFEST_DIR").replace("/packages/wit-schema", ""), + ); + let wasm_bytes = std::fs::read(&path).unwrap(); + let component = wasmtime::component::Component::new(&engine, &wasm_bytes).unwrap(); + let cache = SchemaCache::default(); + let options = SchemaOptions::default(); + + // First call should generate and cache + let schema1 = + generate_schema_cached(&engine, &component, &wasm_bytes, &options, &cache).unwrap(); + + // Second call should return cached result + let schema2 = + generate_schema_cached(&engine, &component, &wasm_bytes, &options, &cache).unwrap(); + + assert_eq!(schema1, schema2, "cached schema should match original"); + + // Verify the digest is in the cache + let digest = wavs_types::ComponentDigest::hash(&wasm_bytes); + assert!( + cache.get(&digest).is_some(), + "cache should contain the schema" + ); + } + + #[test] + fn test_generate_schema_cached_different_bytes_generates_new() { + let engine = make_engine(); + let cache = SchemaCache::default(); + let options = SchemaOptions::default(); + + // Load echo_data (operator world: single "run" export) + let echo_path = format!( + "{}/examples/build/components/echo_data.wasm", + env!("CARGO_MANIFEST_DIR").replace("/packages/wit-schema", ""), + ); + let echo_bytes = std::fs::read(&echo_path).unwrap(); + let echo_component = wasmtime::component::Component::new(&engine, &echo_bytes).unwrap(); + + // Load timer_aggregator (aggregator world: 3 exports) + let agg_path = format!( + "{}/examples/build/components/timer_aggregator.wasm", + env!("CARGO_MANIFEST_DIR").replace("/packages/wit-schema", ""), + ); + let agg_bytes = std::fs::read(&agg_path).unwrap(); + let agg_component = wasmtime::component::Component::new(&engine, &agg_bytes).unwrap(); + + let schema1 = + generate_schema_cached(&engine, &echo_component, &echo_bytes, &options, &cache) + .unwrap(); + let schema2 = + generate_schema_cached(&engine, &agg_component, &agg_bytes, &options, &cache).unwrap(); + + assert_ne!( + schema1, schema2, + "different components should produce different schemas" + ); + + // Verify both are in the cache + let echo_digest = wavs_types::ComponentDigest::hash(&echo_bytes); + let agg_digest = wavs_types::ComponentDigest::hash(&agg_bytes); + assert!(cache.get(&echo_digest).is_some(), "echo should be cached"); + assert!( + cache.get(&agg_digest).is_some(), + "aggregator should be cached" + ); + } +} diff --git a/packages/wit-schema/src/traverse.rs b/packages/wit-schema/src/traverse.rs new file mode 100644 index 000000000..58049b591 --- /dev/null +++ b/packages/wit-schema/src/traverse.rs @@ -0,0 +1,34 @@ +use wasmtime::component::types::{ComponentFunc, ComponentItem}; +use wasmtime::Engine; + +/// Gather all exported functions from a component type, including nested instance exports. +/// +/// Returns a list of (qualified_name, ComponentFunc) pairs. For functions inside +/// a ComponentInstance export, the name is formatted as "instance_name/func_name". +/// Only exported functions are collected (D-05: imports are excluded). +pub fn gather_exports( + component_type: &wasmtime::component::types::Component, + engine: &Engine, +) -> Vec<(String, ComponentFunc)> { + let mut funcs = Vec::new(); + + for (name, item) in component_type.exports(engine) { + match item { + ComponentItem::ComponentFunc(func) => { + funcs.push((name.to_string(), func)); + } + ComponentItem::ComponentInstance(instance) => { + // Recurse into instance exports to find nested functions + for (sub_name, sub_item) in instance.exports(engine) { + if let ComponentItem::ComponentFunc(func) = sub_item { + funcs.push((format!("{}/{}", name, sub_name), func)); + } + } + } + // Skip all other ComponentItem variants (Module, Component, Type, Resource, CoreFunc) + _ => {} + } + } + + funcs +} diff --git a/packages/wit-schema/src/types.rs b/packages/wit-schema/src/types.rs new file mode 100644 index 000000000..bc2891818 --- /dev/null +++ b/packages/wit-schema/src/types.rs @@ -0,0 +1,8 @@ +use std::path::PathBuf; + +/// Options for schema generation. +#[derive(Debug, Clone, Default)] +pub struct SchemaOptions { + /// Optional path to WIT source files for doc comment enrichment. + pub wit_path: Option, +}