Skip to main content

Building Secure WebAssembly Tools with MoonBit and Wassette

Β· 8 min read

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:

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:weather with 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-weather that 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.mbt file 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:

  1. Creates an HTTP request to the wttr.in weather service
  2. Sets the request path, including the city name and format parameters
  3. Sends the request and waits for the response
  4. Extracts the content from the response
  5. 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:

  1. Define clear interfaces
  2. Utilize the efficiency of MoonBit
  3. Run in Wassette's secure sandbox
  4. 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!