Building Secure WebAssembly Tools with MoonBit and Wassette

Welcome to the world of MoonBit and Wassette! This tutorial will guide you step-by-step in building a secure tool based on the WebAssembly Component Model. Through a practical weather query application example, you will learn how to leverage MoonBit's efficiency and Wassette's security features to create powerful AI tools.
Introduction to Wassette and MCPβ
MCP (Model Completion Protocol) is a protocol for AI models to interact with external tools. When an AI needs to perform a specific task (such as network access or data query), it calls the corresponding tool through MCP. This mechanism extends the capabilities of AI but also brings security challenges.
Wassette is a runtime developed by Microsoft based on the WebAssembly Component Model, providing a secure environment for AI systems to execute external tools. It solves potential security risks through sandbox isolation and precise permission control.
Wassette allows tools to run in an isolated environment, with permissions strictly limited by a policy file and interfaces clearly defined by WIT (WebAssembly Interface Type). WIT interfaces are also used to generate data formats for tool interaction.
Overall Processβ
Before we start, let's understand the overall process:
Let's start this journey!
Step 1: Install Necessary Toolsβ
First, we need to install four tools (we assume the MoonBit toolchain is already installed):
- wasm-tools: A WebAssembly toolset for processing and manipulating Wasm files
- wit-deps: A WebAssembly Interface Type dependency manager
- wit-bindgen: A WebAssembly Interface Type binding generator for generating language bindings
- wassette: A runtime based on the Wasm Component Model for executing our tools
Among them, wasm-tools, wit-deps, and wit-bindgen can be installed via cargo (requires Rust to be installed):
cargo install wasm-tools
cargo install wit-deps
cargo install wit-bindgen-cli
Or download from GitHub Releases:
- wit-bindgen: https://github.com/bytecodealliance/wit-bindgen/releases/tag/v0.45.0
- wasm-tools: https://github.com/bytecodealliance/wasm-tools/releases/tag/v1.238.0
- wit-deps: https://github.com/bytecodealliance/wit-deps/releases/tag/v0.5.0
- wassette: https://github.com/microsoft/wassette/releases/tag/v0.3.4
Step 2: Define the Interfaceβ
Interface definition is the core of the entire workflow. We use the WebAssembly Interface Type (WIT) format to define the component's interface.
First, create the project directory and necessary subdirectories:
mkdir -p weather-app/wit
cd weather-app
Create deps.tomlβ
Create a deps.toml file in the wit directory to define project dependencies:
cli = "https://github.com/WebAssembly/wasi-cli/archive/refs/tags/v0.2.7.tar.gz"
http = "https://github.com/WebAssembly/wasi-http/archive/refs/tags/v0.2.7.tar.gz"
These dependencies specify the WASI (WebAssembly System Interface) components we will use:
cli: Provides command-line interface functionality. Not used in this example.http: Provides HTTP client and server functionality. The client functionality is used in this example.
Then, run wit-deps update. This command will fetch the dependencies and expand them in the wit/deps/ directory.
Create world.witβ
Next, create a world.wit file to define our component interface. WIT is a declarative interface description language designed for the WebAssembly Component Model. It allows us to define how components interact with each other without worrying about specific implementation details. For more details, you can check the Component Model manual.
package peter-jerry-ye:weather@0.1.0;
world w {
import wasi:http/outgoing-handler@0.2.7;
export get-weather: func(city: string) -> result<string, string>;
}
This WIT file defines:
- A package named
peter-jerry-ye:weatherwith version 0.1.0 - A world named
w, which is the main interface of the component - Imports the outgoing request interface of WASI HTTP
- Exports a function named
get-weatherthat takes a city name string and returns a result (a weather information string on success, or an error message string on failure)
Step 3: Generate Codeβ
Now that we have defined the interface, the next step is to generate the corresponding code skeleton. We use the wit-bindgen tool to generate binding code for MoonBit:
# Make sure you are in the project root directory
wit-bindgen moonbit --derive-eq --derive-show --derive-error wit
This command will read the files in the wit directory and generate the corresponding MoonBit code. The generated files will be placed in the gen directory.
Note: The current version of the generated code may contain some warnings, which will be fixed in future updates.
The generated directory structure should look like this:
.
βββ ffi/
βββ gen/
β βββ ffi.mbt
β βββ moon.pkg.json
β βββ world
β β βββ w
β β βββ moon.pkg.json
β β βββ stub.mbt
β βββ world_w_export.mbt
βββ interface/
βββ moon.mod.json
βββ Tutorial.md
βββ wit/
βββ world/
These generated files include:
- Basic FFI (Foreign Function Interface) code (
ffi/) - Generated import functions (
world/,interface/) - Wrappers for exported functions (
gen/) - The
stub.mbtfile to be implemented
Step 4: Modify the Generated Codeβ
Now we need to modify the generated stub file to implement our weather query functionality. The main files to edit are gen/world/w/stub.mbt and moon.pkg.json in the same directory. Before that, let's add dependencies to facilitate implementation:
moon update
moon add moonbitlang/x
{
"import": [
"peter-jerry-ye/weather/interface/wasi/http/types",
"peter-jerry-ye/weather/interface/wasi/http/outgoingHandler",
"peter-jerry-ye/weather/interface/wasi/io/poll",
"peter-jerry-ye/weather/interface/wasi/io/streams",
"peter-jerry-ye/weather/interface/wasi/io/error",
"moonbitlang/x/encoding"
]
}
Let's look at the generated stub code:
// Generated by `wit-bindgen` 0.44.0.
///|
pub fn (city : String) -> Result[String, String]
get_weather(String
city : String
String) -> enum Result[A, B] {
Err(B)
Ok(A)
}
Result[String
String, String
String] {
... // This is the part we need to implement
}
Now, we need to add the implementation code to request weather information using an HTTP client. Edit the gen/world/w/stub.mbt file as follows:
///|
pub fn (city : String) -> Result[String, String]
get_weather(String
city : String
String) -> enum Result[A, B] {
Err(B)
Ok(A)
}
Result[String
String, String
String] {
(try? (city : String) -> String raise
Use MoonBit's error handling mechanism to simplify implementation
get_weather_(String
city)).(self : Result[String, Error], f : (Error) -> String) -> Result[String, String]
Maps the value of a Result if it is Err into another, otherwise returns the Ok value unchanged.
Example
let x: Result[Int, String] = Err("error")
let y = x.map_err((v : String) => { v + "!" })
assert_eq(y, Err("error!"))
map_err(_.(self : Error) -> String
to_string())
}
///| Use MoonBit's error handling mechanism to simplify implementation
fn (city : String) -> String raise
Use MoonBit's error handling mechanism to simplify implementation
get_weather_(String
city : String
String) -> String
String raise {
let Unit
request = (Unit) -> Unit
@types.OutgoingRequest::outgoing_request(
() -> Unit
@types.Fields::fields(),
)
if Unit
request.(Unit) -> Unit
set_authority(Unit
Some("wttr.in")) is (_/0) -> Unit
Err(_) {
(msg : String, loc~ : SourceLoc = _) -> Unit raise Failure
Raises a Failure error with a given message and source location.
Parameters:
message : A string containing the error message to be included in the
failure.
location : The source code location where the failure occurred.
Automatically provided by the compiler when not specified.
Returns a value of type T wrapped in a Failure error type.
Throws an error of type Failure with a message that includes both the
source location and the provided error message.
fail("Invalid Authority")
}
if Unit
request.(Unit) -> Unit
set_path_with_query(Unit
Some("/\{String
city}?format=3")) is (_/0) -> Unit
Err(_) {
(msg : String, loc~ : SourceLoc = _) -> Unit raise Failure
Raises a Failure error with a given message and source location.
Parameters:
message : A string containing the error message to be included in the
failure.
location : The source code location where the failure occurred.
Automatically provided by the compiler when not specified.
Returns a value of type T wrapped in a Failure error type.
Throws an error of type Failure with a message that includes both the
source location and the provided error message.
fail("Invalid path with query")
}
if Unit
request.(Unit) -> Unit
set_method(Unit
Get) is (_/0) -> Unit
Err(_) {
(msg : String, loc~ : SourceLoc = _) -> Unit raise Failure
Raises a Failure error with a given message and source location.
Parameters:
message : A string containing the error message to be included in the
failure.
location : The source code location where the failure occurred.
Automatically provided by the compiler when not specified.
Returns a value of type T wrapped in a Failure error type.
Throws an error of type Failure with a message that includes both the
source location and the provided error message.
fail("Invalid Method")
}
let Unit
future_response = (Unit, Unit) -> Unit
@outgoingHandler.handle(Unit
request, Unit
None).() -> Unit
unwrap_or_error()
defer Unit
future_response.() -> Unit
drop()
let Unit
pollable = Unit
future_response.() -> Unit
subscribe()
defer Unit
pollable.() -> Unit
drop()
Unit
pollable.() -> Unit
block()
let Unit
response = Unit
future_response.() -> Unit
get().() -> Unit
unwrap().() -> Unit
unwrap().() -> Unit
unwrap_or_error()
defer Unit
response.() -> Unit
drop()
let Unit
body = Unit
response.() -> Unit
consume().() -> Unit
unwrap()
defer Unit
body.() -> Unit
drop()
let Unit
stream = Unit
body.() -> Unit
stream().() -> Unit
unwrap()
defer Unit
stream.() -> Unit
drop()
let Unit
decoder = (Unit) -> Unit
@encoding.decoder(Unit
UTF8)
let StringBuilder
builder = type StringBuilder
StringBuilder::(size_hint? : Int) -> StringBuilder
Creates a new string builder with an optional initial capacity hint.
Parameters:
size_hint : An optional initial capacity hint for the internal buffer. If
less than 1, a minimum capacity of 1 is used. Defaults to 0. It is the size of bytes,
not the size of characters. size_hint may be ignored on some platforms, JS for example.
Returns a new StringBuilder instance with the specified initial capacity.
new()
loop Unit
stream.(Int) -> Unit
blocking_read(1024) {
(Unit) -> Unit
Ok(Unit
bytes) => {
Unit
decoder.(Unit, StringBuilder, Bool) -> Unit
decode_to(
Unit
bytes.() -> Unit
unsafe_reinterpret_as_bytes()[:],
StringBuilder
builder,
Bool
stream=true,
)
continue Unit
stream.(Int) -> Unit
blocking_read(1024)
}
(_/0) -> Unit
Err(_/0
Closed) => Unit
decoder.(String, StringBuilder, Bool) -> Unit
decode_to("", StringBuilder
builder, Bool
stream=false)
(_/0) -> Unit
Err((Unit) -> _/0
LastOperationFailed(Unit
e)) => {
defer Unit
e.() -> Unit
drop()
(msg : String, loc~ : SourceLoc = _) -> Unit raise Failure
Raises a Failure error with a given message and source location.
Parameters:
message : A string containing the error message to be included in the
failure.
location : The source code location where the failure occurred.
Automatically provided by the compiler when not specified.
Returns a value of type T wrapped in a Failure error type.
Throws an error of type Failure with a message that includes both the
source location and the provided error message.
fail(Unit
e.() -> String
to_debug_string())
}
}
StringBuilder
builder.(self : StringBuilder) -> String
Returns the current content of the StringBuilder as a string.
to_string()
}
This code implements the following functions:
- Creates an HTTP request to the
wttr.inweather service - Sets the request path, including the city name and format parameters
- Sends the request and waits for the response
- Extracts the content from the response
- Decodes the content and returns the weather information string
Step 5: Build the Projectβ
Now that we have implemented the functionality, the next step is to build the project.
moon build --target wasm
wasm-tools component embed wit target/wasm/release/build/gen/gen.wasm -o core.wasm --encoding utf16
wasm-tools component new core.wasm -o weather.wasm
After a successful build, a weather.wasm file will be generated in the project root directory. This is our WebAssembly component.
You can then load it into Wassette:
wassette component load file://$(pwd)/component.wasm
Step 6 (Optional): Configure Security Policyβ
Wassette strictly controls the permissions of WebAssembly components β a key part of ensuring tool security. Through fine-grained permission control, we can ensure the tool only performs expected operations.
In this example, we want it to access wttr.in, so we can grant permission using:
wassette permission grant network weather wttr.in
Step 7: Interact with AIβ
Finally, we can use Wassette to run our component and interact with AI. For example, in VSCode Copilot, modify .vscode/mcp.json:
{
"servers": {
"wassette": {
"command": "wassette",
"args": ["serve", "--disable-builtin-tools", "--stdio"],
"type": "stdio"
}
},
"inputs": []
}
After restarting Wassette, you can ask AI:
Using Wassette, load the component
./component.wasm(note the use of the file schema) and query the weather for Shenzhen.
The AI will call load-component and get-weather in sequence, returning:
The component has been successfully loaded. The weather in Shenzhen is: βοΈ +30Β°C.
Summaryβ
At this point, we have successfully created a secure MCP tool based on the WebAssembly Component Model, which can:
- Define clear interfaces
- Utilize the efficiency of MoonBit
- Run in Wassette's secure sandbox
- Interact with AI
Wassette is currently at version 0.3.4 and still lacks some MCP concepts, such as prompts, workspaces, reverse retrieval of user instructions, and AI generation capabilities. But it demonstrates how quickly an MCP can be built using the Wasm Component Model.
MoonBit will continue to improve its component model capabilities, including adding asynchronous support from the upcoming WASIp3 and simplifying the development process. Stay tuned!