WASM-Sandboxed MCP Tool Implementations: Containing the Blast Radius of Agent Tool Execution
The Problem
Standard MCP server implementations run tool functions as native code in the same process as the MCP server. Every tool shares the same process boundary: the same filesystem access, the same environment variables, the same network socket capabilities, the same in-process memory. This is not an oversight — it is how most software is structured. The oversight is treating it as acceptable when the code calling those tools is an AI agent operating on user-controlled instructions.
When an agent calls read_file, create_github_issue, search_database, and send_email as tools, all four implementations run in the same OS process. If any one of those tools is compromised — through a supply chain attack on its npm or pip dependencies, through a vulnerability in the tool’s own parsing logic, or through a malicious contribution to an open source MCP server — the attacker gains access to everything the process can access. The GitHub token in GITHUB_TOKEN. The database URL in DATABASE_URL. The contents of files the read_file tool is legitimately allowed to read. And critically: the execution context of every other tool in the server.
This is coarse-grained isolation at the OS process level. All tools share a trust boundary set by whichever tool has the broadest legitimate access. An attacker who compromises the file reading tool does not need file reading capabilities — they already have the GitHub token sitting in the process environment.
The structural fix is per-tool isolation: each tool implementation runs in its own sandbox with only the capabilities it needs explicitly granted. WebAssembly provides this at a level that OS processes do not: WASM modules execute in a sandboxed linear memory region with no ambient host access. A WASM module cannot read files, open network connections, or read environment variables unless the host runtime explicitly provides those capabilities at module instantiation time. The capability grant is declared in code, is auditable, and cannot be bypassed by the WASM module itself.
Two tools make this practical:
Extism (extism.org) is a plugin system built on Wasmtime. Tool implementations are compiled to WASM and loaded as plugins into an existing MCP server host. Each plugin receives only the host functions its manifest declares and only the WASI capabilities (filesystem paths, environment variables, network) the host grants at instantiation. The host is a Go, Python, Rust, or Node.js process that loads and orchestrates the plugins.
Fermyon Spin is a WASM-first microservices framework. Tool implementations are Spin components — WASM modules with WASI Preview 2 interfaces — each with an explicit allowed_outbound_hosts list, per-component key-value access, and filesystem mounts declared in spin.toml. Spin is better suited for greenfield deployments where the MCP server itself is being built from scratch, rather than retrofitting isolation into an existing server.
Both converge on the same architectural primitive: one tool = one WASM module = one capability set. A compromised module is contained within the boundary of what that module was granted.
Threat Model
Compromised supply chain dependency in one tool reads all other tools’ credentials. An MCP server bundles ten tools. The html-parser npm package used by the web-scraping tool has a malicious version published after a maintainer account takeover. When the MCP server loads, the malicious package reads process.env, finds GITHUB_TOKEN, DATABASE_URL, OPENAI_API_KEY, and exfiltrates them to attacker-controlled infrastructure. None of the other nine tools were compromised. With WASM sandboxing: each tool’s environment variables are an explicit per-tool grant. The web-scraping tool’s env_vars map contains only SCRAPER_RATE_LIMIT. There is no GITHUB_TOKEN to steal.
Malicious tool implementation calls unintended external endpoints. A tool implementation is modified to make outbound HTTP calls to a C2 server. In a native MCP server, the process has unrestricted network access; the call succeeds. With Extism, outbound HTTP goes through a host function that enforces an allowlist — a call to evil.example.com is blocked at the host function level, before the network stack. With Spin, allowed_outbound_hosts in the component manifest is enforced by the Spin runtime itself; any host not in the list receives a WASI error at the socket level.
Bug in one tool’s dependency causes memory corruption affecting other tools’ data. In a native process, a use-after-free in one tool’s memory can overwrite adjacent heap objects belonging to another tool’s data structures. WASM linear memory is per-module and bounded. Each module’s memory is a flat byte array allocated and managed by the WASM runtime. One module cannot address memory outside its own linear memory region; the sandbox enforces this at the instruction level, not at the OS level.
Agent prompt injection causes a tool to attempt privilege escalation. A malicious document processed by a file-reading tool contains instructions intended to make the agent call the GitHub tool with attacker-controlled parameters. Even if this succeeds at the agent level, the GitHub tool’s WASM module runs with only GITHUB_TOKEN in its environment and only api.github.com in its network allowlist. It cannot read the document the file-reading tool processed, because that data never left the file-reading tool’s sandbox.
Hardening Configuration
1. Tool Implementation as Extism Plugin (Rust → WASM)
The Extism Plugin Development Kit (PDK) provides the guest-side interface. Tool implementations use the PDK to declare their entry points and use standard Rust I/O — the WASI layer in Wasmtime translates those calls to the capabilities the host granted.
// file_reader_tool/src/lib.rs
// Extism plugin: implements a read_file MCP tool.
// Compiled to WASM — runs in isolated Wasmtime sandbox.
// Has no ambient host access; can only access paths the host preopened.
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct ReadFileParams {
path: String,
max_bytes: Option<usize>,
}
#[derive(Serialize)]
struct ReadFileResult {
content: String,
bytes_read: usize,
truncated: bool,
}
#[plugin_fn]
pub fn read_file(params: Json<ReadFileParams>) -> FnResult<Json<ReadFileResult>> {
let path = ¶ms.0.path;
let max_bytes = params.0.max_bytes.unwrap_or(1_048_576); // 1 MiB default
// std::fs::read_to_string goes through WASI.
// If `path` is outside the host's preopened directories, WASI returns
// EACCES or ENOTCAPABLE at the syscall level — the WASM module
// never touches the actual host filesystem.
let raw = std::fs::read(path)
.map_err(|e| Error::msg(format!("read failed: {e}")))?;
let truncated = raw.len() > max_bytes;
let truncated_bytes = &raw[..raw.len().min(max_bytes)];
let content = String::from_utf8_lossy(truncated_bytes).into_owned();
let bytes_read = truncated_bytes.len();
Ok(Json(ReadFileResult { content, bytes_read, truncated }))
}
# file_reader_tool/Cargo.toml
[package]
name = "file-reader-tool"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
extism-pdk = "1.0"
serde = { version = "1.0", features = ["derive"] }
# Build the WASM plugin
cargo build --target wasm32-wasip1 --release
# Output: target/wasm32-wasip1/release/file_reader_tool.wasm
# This is the artifact that gets loaded by the MCP server host.
# It has no network access, no environment variables, and no filesystem
# access until the host grants specific capabilities at instantiation.
# Sign the WASM binary before deployment
cosign sign-blob \
--key cosign.key \
--output-signature plugins/file_reader_tool.wasm.sig \
plugins/file_reader_tool.wasm
A parallel GitHub tool uses extism_pdk::http_request for its outbound calls:
// github_tool/src/lib.rs
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateIssueParams {
owner: String,
repo: String,
title: String,
body: String,
}
#[derive(Serialize)]
struct CreateIssueResult {
issue_number: u64,
html_url: String,
}
#[plugin_fn]
pub fn create_github_issue(
params: Json<CreateIssueParams>,
) -> FnResult<Json<CreateIssueResult>> {
let token = config::get("GITHUB_TOKEN")
.ok_or_else(|| Error::msg("GITHUB_TOKEN not configured"))?;
// extism_pdk::http_request goes through the host's HTTP host function.
// The host's implementation enforces an allowed-hosts list.
// A request to any host not on that list returns an error here —
// the WASM module cannot bypass this by opening raw TCP sockets,
// because WASI sockets are not granted to this module.
let request = HttpRequest::new(
&format!(
"https://api.github.com/repos/{}/{}/issues",
params.0.owner, params.0.repo
),
)
.with_method("POST")
.with_header("Authorization", &format!("Bearer {token}"))
.with_header("Content-Type", "application/json");
let body = serde_json::json!({
"title": params.0.title,
"body": params.0.body,
});
let response = http::request::<Vec<u8>>(&request, Some(body.to_string().as_bytes()))
.map_err(|e| Error::msg(format!("HTTP request failed: {e}")))?;
if response.status_code() != 201 {
return Err(Error::msg(format!(
"GitHub API error {}: {}",
response.status_code(),
String::from_utf8_lossy(response.body())
)));
}
let result: serde_json::Value = serde_json::from_slice(response.body())
.map_err(|e| Error::msg(format!("parse error: {e}")))?;
Ok(Json(CreateIssueResult {
issue_number: result["number"].as_u64().unwrap_or(0),
html_url: result["html_url"].as_str().unwrap_or("").to_string(),
}))
}
2. MCP Server Host: Loading Plugins with Explicit Capability Grants
The host-side orchestrator loads each tool as a separate Extism plugin instance. Each plugin receives only the filesystem mounts, environment variables, and host functions it specifically needs. The grants are declared explicitly in code — there is no implicit ambient access.
# mcp_server/server.py
# MCP server orchestrator: loads each tool as an isolated Extism plugin.
# Requires: pip install extism
import json
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import extism
@dataclass
class ToolCapabilities:
"""Explicit capability grant for one tool's WASM plugin."""
wasm_path: str
sig_path: str
# Filesystem: list of (host_path, guest_path) tuples.
# Only these directories are visible inside the WASM module.
preopened_dirs: list[tuple[str, str]] = field(default_factory=list)
# Network: hostnames the plugin may reach via extism HTTP host function.
allowed_hosts: list[str] = field(default_factory=list)
# Environment: only these vars are visible; never the full process env.
env_vars: dict[str, str] = field(default_factory=dict)
# Resource limits
memory_pages: int = 256 # 256 × 64 KiB = 16 MiB
call_timeout_ms: int = 10_000
class SandboxedMCPServer:
def __init__(self, cosign_public_key: str):
self._tools: dict[str, tuple[extism.Plugin, ToolCapabilities]] = {}
self._cosign_key = cosign_public_key
def _verify_plugin_signature(self, wasm_path: str, sig_path: str) -> None:
"""Reject WASM plugins that fail signature verification.
Never load an unverified plugin. A tampered WASM binary that passes
host function calls is as dangerous as a native library backdoor.
"""
result = subprocess.run(
[
"cosign", "verify-blob",
"--key", self._cosign_key,
"--signature", sig_path,
wasm_path,
],
capture_output=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(
f"Signature verification failed for {wasm_path}:\n"
f"{result.stderr.decode()}"
)
def register_tool(self, tool_name: str, caps: ToolCapabilities) -> None:
"""Load a tool implementation as an isolated WASM plugin.
The plugin receives exactly the capabilities declared in `caps`.
No other filesystem paths, environment variables, or network
hosts are accessible to the WASM module.
"""
self._verify_plugin_signature(caps.wasm_path, caps.sig_path)
# Build the Extism manifest. The manifest controls WASI capabilities.
manifest = extism.Manifest(
wasm=[extism.Wasm.path(caps.wasm_path)],
memory=extism.Memory(max_pages=caps.memory_pages),
# Allowed hosts restricts extism_pdk::http_request targets.
# The plugin cannot reach hosts outside this list.
allowed_hosts=caps.allowed_hosts,
timeout_ms=caps.call_timeout_ms,
)
# WASI context: only the declared directories and env vars.
# The WASM module's std::env::vars() sees only caps.env_vars.
# It cannot read GITHUB_TOKEN unless it's in this dict.
plugin = extism.Plugin(
manifest,
wasi=True,
wasi_options={
"preopened_dirs": caps.preopened_dirs,
"envs": caps.env_vars,
},
)
self._tools[tool_name] = (plugin, caps)
def call_tool(self, tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
if tool_name not in self._tools:
raise KeyError(f"Unknown tool: {tool_name!r}")
plugin, caps = self._tools[tool_name]
# All data crossing the host/guest boundary is JSON-serialised.
# There is no shared memory between tools or between a tool and the host.
raw_params = json.dumps(params).encode()
try:
raw_result = plugin.call(tool_name, raw_params)
except extism.Error as exc:
# Plugin errors are reported as Extism exceptions — they do not
# propagate to other tools or affect the host process state.
raise ToolExecutionError(tool_name, str(exc)) from exc
return json.loads(raw_result)
class ToolExecutionError(Exception):
def __init__(self, tool_name: str, message: str):
super().__init__(f"Tool {tool_name!r} failed: {message}")
self.tool_name = tool_name
# ── Registration: each tool gets exactly what it needs ─────────────────────
server = SandboxedMCPServer(cosign_public_key="/etc/mcp/cosign.pub")
# File reader: one workspace directory, no network, no environment variables.
server.register_tool(
"read_file",
ToolCapabilities(
wasm_path="/opt/mcp/plugins/file_reader_tool.wasm",
sig_path="/opt/mcp/plugins/file_reader_tool.wasm.sig",
preopened_dirs=[("/var/mcp/workspace", "/workspace")],
allowed_hosts=[], # No outbound HTTP from this tool
env_vars={}, # No env vars — none needed
memory_pages=128, # 8 MiB for a file reading tool
call_timeout_ms=5_000,
),
)
# GitHub tool: no filesystem access, only api.github.com, only its token.
server.register_tool(
"create_github_issue",
ToolCapabilities(
wasm_path="/opt/mcp/plugins/github_tool.wasm",
sig_path="/opt/mcp/plugins/github_tool.wasm.sig",
preopened_dirs=[], # No filesystem access
allowed_hosts=["api.github.com"],
env_vars={"GITHUB_TOKEN": os.environ["GITHUB_TOKEN"]},
memory_pages=64,
call_timeout_ms=15_000,
),
)
# Database query tool: only its connection string, no filesystem, no other env.
server.register_tool(
"query_database",
ToolCapabilities(
wasm_path="/opt/mcp/plugins/db_query_tool.wasm",
sig_path="/opt/mcp/plugins/db_query_tool.wasm.sig",
preopened_dirs=[],
allowed_hosts=["db.internal.example.com"],
env_vars={"DATABASE_URL": os.environ["DATABASE_URL"]},
memory_pages=256,
call_timeout_ms=30_000,
),
)
The critical property: os.environ["GITHUB_TOKEN"] is passed only to create_github_issue. The read_file tool’s WASM module, if compromised, calls std::env::var("GITHUB_TOKEN") and receives NotPresent. The file-reading tool’s WASI environment does not contain it.
3. Fermyon Spin: Greenfield MCP Tool Server
For MCP servers built from scratch, Spin eliminates the orchestration boilerplate. Each tool is an HTTP-triggered Spin component; the MCP server dispatches tool calls as internal HTTP requests. spin.toml is the complete capability policy — what is not listed is not accessible.
# spin.toml
spin_manifest_version = 2
[application]
name = "mcp-tool-server"
version = "1.0.0"
description = "MCP tool server: each tool is an isolated Spin component"
# ── MCP dispatcher: receives tool calls, routes to components ──────────────
[[trigger.http]]
route = "/mcp/tools/call"
component = "dispatcher"
[component.dispatcher]
source = "dispatcher/dispatcher.wasm"
# Dispatcher needs to reach other components (internal only)
allowed_outbound_hosts = ["http://localhost:3000"]
# No filesystem, no external network, no secrets
# ── File reader tool ───────────────────────────────────────────────────────
[[trigger.http]]
route = "/internal/tools/read_file"
component = "file-reader"
[component.file-reader]
source = "tools/file_reader.wasm"
# Mount only the workspace directory. No other paths are accessible.
# The WASI filesystem sees /workspace; requests for /etc, /home, /var/mcp
# return ENOTCAPABLE — not ENOENT. The module cannot probe whether
# other paths exist.
files = [{ source = "/var/mcp/workspace", destination = "/workspace" }]
# No allowed_outbound_hosts = zero network access from this component.
# No [component.file-reader.variables] = zero env vars.
# ── GitHub tool ────────────────────────────────────────────────────────────
[[trigger.http]]
route = "/internal/tools/create_github_issue"
component = "github-tool"
[component.github-tool]
source = "tools/github_tool.wasm"
allowed_outbound_hosts = ["https://api.github.com:443"]
# No filesystem access for this component.
# Secret injection via Spin's variable system — not inline in spin.toml.
[component.github-tool.variables]
github_token = "{{ github_token }}"
# ── Database query tool ────────────────────────────────────────────────────
[[trigger.http]]
route = "/internal/tools/query_database"
component = "db-query"
[component.db-query]
source = "tools/db_query.wasm"
allowed_outbound_hosts = ["postgres://db.internal.example.com:5432"]
[component.db-query.variables]
database_url = "{{ database_url }}"
# key_value_stores grants access to a named KV bucket for caching.
# Not granted here — this tool has no caching layer.
# ── Email tool ─────────────────────────────────────────────────────────────
[[trigger.http]]
route = "/internal/tools/send_email"
component = "email-tool"
[component.email-tool]
source = "tools/email_tool.wasm"
allowed_outbound_hosts = ["smtps://smtp.sendgrid.net:465"]
[component.email-tool.variables]
sendgrid_api_key = "{{ sendgrid_api_key }}"
# This component cannot read /var/mcp/workspace.
# It cannot reach api.github.com.
# It receives only the SendGrid key, not any other credential.
Deploy secrets via Spin’s variable provider (Vault, AWS Secrets Manager, or environment injection at the SpinKube level) — never inline in spin.toml:
# SpinKube: inject secrets as Kubernetes Secrets, not in spin.toml
kubectl create secret generic mcp-tool-secrets \
--from-literal=github_token="${GITHUB_TOKEN}" \
--from-literal=database_url="${DATABASE_URL}" \
--from-literal=sendgrid_api_key="${SENDGRID_API_KEY}"
# SpinApp manifest references the Kubernetes secret
# spin.toml variables resolve at runtime from the mounted secret store
4. Inter-Tool Data Passing Without Shared State
Native MCP servers can accidentally share state through global variables, module-level caches, or objects passed by reference between tool implementations. WASM modules share no memory by default — data between tools must pass explicitly through the orchestrator.
# mcp_server/orchestration.py
import re
def sanitise_tool_output(output: dict) -> dict:
"""
Scrub tool output before passing it to another tool.
This is the explicit trust boundary between tools. Data that leaves
one sandbox enters the orchestrator, is validated and sanitised here,
then enters the next sandbox. There is no direct module-to-module
communication.
"""
if not isinstance(output, dict):
raise ValueError("Tool output must be a JSON object")
sanitised = {}
for key, value in output.items():
if isinstance(value, str):
# Strip null bytes and control characters that could confuse
# downstream tools or be used for injection.
value = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', value)
# Enforce a maximum field length
value = value[:65_536]
sanitised[key] = value
return sanitised
async def summarise_workspace_file_to_issue(
server: SandboxedMCPServer,
file_path: str,
issue_owner: str,
issue_repo: str,
) -> dict:
"""
Orchestrate two sandboxed tools: read a file, then create a GitHub issue.
The file reader cannot see GitHub credentials.
The GitHub tool cannot see the workspace filesystem.
Data flows only through this function — never between WASM modules.
"""
# Tool 1: read file — runs in filesystem-sandboxed module
file_result = server.call_tool("read_file", {"path": file_path})
# Sanitise before crossing sandbox boundary
clean_result = sanitise_tool_output(file_result)
if clean_result.get("truncated"):
content = clean_result["content"] + "\n\n[File truncated at 1 MiB]"
else:
content = clean_result["content"]
# Tool 2: create issue — runs in network-sandboxed module
# Receives only the sanitised file content, not filesystem access
issue_result = server.call_tool(
"create_github_issue",
{
"owner": issue_owner,
"repo": issue_repo,
"title": f"Summary: {file_path}",
"body": content[:65_000], # GitHub issue body limit
},
)
return issue_result
5. WASM Module Signature Verification
A WASM binary is just bytes. Without verification, a supply chain attack that replaces github_tool.wasm with a malicious version runs with the same GitHub token grant as the legitimate plugin. Verification must be a hard gate — not a logged warning.
# mcp_server/plugin_loader.py
import hashlib
import subprocess
from pathlib import Path
def verify_plugin(
wasm_path: str,
sig_path: str,
cosign_public_key: str,
) -> None:
"""
Verify WASM plugin signature with cosign.
Raises RuntimeError if verification fails. Never returns silently on failure.
"""
path = Path(wasm_path)
if not path.exists():
raise FileNotFoundError(f"Plugin not found: {wasm_path}")
result = subprocess.run(
[
"cosign", "verify-blob",
"--key", cosign_public_key,
"--signature", sig_path,
str(path),
],
capture_output=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(
f"Signature verification FAILED for {wasm_path}.\n"
f"cosign stderr: {result.stderr.decode().strip()}\n"
"Refusing to load unverified plugin."
)
def compute_plugin_sha256(wasm_path: str) -> str:
"""Return the hex SHA-256 of the WASM binary."""
h = hashlib.sha256()
with open(wasm_path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
Sign plugins in CI as part of the build pipeline, not as a manual step:
# .github/workflows/build-plugins.yml (pinned to SHA, not tag)
- name: Build WASM plugins
run: |
for tool in file_reader_tool github_tool db_query_tool email_tool; do
(cd tools/${tool} && cargo build --target wasm32-wasip1 --release)
cp tools/${tool}/target/wasm32-wasip1/release/${tool}.wasm dist/plugins/
done
- name: Sign WASM plugins
env:
COSIGN_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
run: |
for wasm in dist/plugins/*.wasm; do
echo "${COSIGN_KEY}" > /tmp/cosign.key
cosign sign-blob \
--key /tmp/cosign.key \
--output-signature "${wasm}.sig" \
"${wasm}"
rm /tmp/cosign.key
echo "Signed: ${wasm}"
done
- name: Verify signatures before publishing
run: |
for wasm in dist/plugins/*.wasm; do
cosign verify-blob \
--key cosign.pub \
--signature "${wasm}.sig" \
"${wasm}" || { echo "FAILED: ${wasm}"; exit 1; }
done
6. Runtime Capability Audit: What Each Tool Can Actually Do
Document the effective capability matrix for every tool. This is the security-relevant artifact — it answers “what can this tool do if compromised?”
| Tool | Filesystem | Network | Environment | Memory limit |
|---|---|---|---|---|
read_file |
/workspace (r/o) |
None | None | 8 MiB |
create_github_issue |
None | api.github.com:443 |
GITHUB_TOKEN |
4 MiB |
query_database |
None | db.internal:5432 |
DATABASE_URL |
16 MiB |
send_email |
None | smtp.sendgrid.net:465 |
SENDGRID_API_KEY |
4 MiB |
Each row is derived from the ToolCapabilities struct in the server configuration or the corresponding spin.toml component stanza. The table should be generated programmatically from config, not maintained manually.
7. Measuring Sandbox Overhead
The primary cost of WASM sandboxing is per-call overhead: serialising parameters to JSON, invoking the WASM function through the runtime, and deserialising results. Module instantiation is amortised by reusing plugin instances.
# benchmarks/benchmark_tool_call.py
import statistics
import time
import json
import os
import tempfile
import extism
def benchmark_tool_call(
plugin: extism.Plugin,
function_name: str,
params: dict,
iterations: int = 500,
) -> dict[str, float]:
times = []
raw_params = json.dumps(params).encode()
for _ in range(iterations):
start = time.perf_counter()
plugin.call(function_name, raw_params)
elapsed_ms = (time.perf_counter() - start) * 1000
times.append(elapsed_ms)
return {
"mean_ms": statistics.mean(times),
"p50_ms": statistics.median(times),
"p95_ms": statistics.quantiles(times, n=20)[18], # 95th percentile
"p99_ms": statistics.quantiles(times, n=100)[98],
"stdev_ms": statistics.stdev(times),
}
# Typical results on a 2024-era x86-64 host (Wasmtime 15, Ryzen 9 5950X):
#
# Benchmark: read_file — 4 KiB file, workspace on local SSD
# Native Python open(): 0.06ms mean, 0.08ms p95
# Extism WASM plugin: 1.8ms mean, 2.4ms p95
# Overhead: ~30x on mean, dominated by JSON serialisation
#
# Benchmark: create_github_issue — mocked GitHub API response (no network)
# Native Python requests: 0.4ms mean (excluding actual HTTP round-trip)
# Extism WASM plugin: 2.1ms mean
# Overhead: ~5x on mean
#
# When the actual I/O dominates (real network calls: 50-500ms; real disk I/O:
# 1-10ms for small files), the WASM overhead is noise — 2ms on a 200ms API call
# is 1%. The overhead matters only for CPU-bound tools or sub-millisecond
# latency requirements, which are unusual for MCP tool workloads.
#
# Module instantiation cost (paid once at startup, not per call):
# Wasmtime AOT compilation of a 200 KiB plugin: ~25ms
# Wasmtime instantiation from AOT cache: ~0.8ms
# Pre-compile plugins at server startup to amortise this cost.
Pre-compile plugins to Wasmtime’s AOT cache at server startup, not on first call:
# At server startup: AOT compile all plugins
# This adds ~25ms per plugin at startup but eliminates JIT overhead per call.
plugin = extism.Plugin(
manifest,
wasi=True,
wasi_options={...},
compile_cache="/var/mcp/plugin-cache", # Persist AOT artifacts
)
Expected Behaviour
WASI capability violation — filesystem access outside preopened directory:
# read_file tool attempts to access /etc/passwd
# (not under /workspace, which is the only preopened dir)
Tool 'read_file' failed: read failed: ENOTCAPABLE: /etc/passwd
The error is ENOTCAPABLE, not ENOENT. The module cannot determine whether /etc/passwd exists — it has no capability to access that path at all. An attacker using the read_file tool to probe the host filesystem gets no information beyond the preopened directories.
Extism HTTP allowlist violation — outbound call to unlisted host:
# github_tool attempts to call api.evil.com via extism_pdk::http_request
# (not in allowed_hosts=["api.github.com"])
Tool 'create_github_issue' failed: HTTP request failed: host not allowed: api.evil.com
This error is returned from the Extism host function before any network activity. The DNS lookup does not occur. The WASM module has no path to make a raw TCP connection because WASI sockets are not granted.
Spin component outbound host violation:
# Spin runtime blocks the outbound request at the WASI layer
Error: Component "github-tool" attempted outbound connection to
"https://api.evil.com/exfiltrate" which is not in allowed_outbound_hosts.
Visible in Spin logs. Combine with log-based alerting: any attempted outbound connection log line from a Spin component is an anomaly signal requiring investigation.
Signature verification failure at plugin load:
RuntimeError: Signature verification FAILED for /opt/mcp/plugins/github_tool.wasm.
cosign stderr: error: verifying blob: invalid signature
Refusing to load unverified plugin.
The server does not start if any plugin fails verification. This is the correct failure mode — a server that loads some unverified plugins and skips the check for others provides no security guarantee.
Trade-offs
5-10x per-call latency overhead vs. native. Acceptable for tool workloads where I/O dominates: a 2ms WASM overhead on a 150ms GitHub API call is irrelevant. Unacceptable for latency-sensitive or CPU-intensive tools. Profile before assuming it matters; it usually does not for MCP tool workloads.
Per-tool WASM compilation. Each tool implementation must be compiled to wasm32-wasip1 or wasm32-wasi. Not all languages have production-quality WASM targets. Go produces large WASM binaries (10+ MiB) without TinyGo. Python cannot be compiled to WASM without significant toolchain investment (Pyodide, py2wasm). Rust and C/C++ have excellent targets. TypeScript/JavaScript runs via wasm32-wasip1 with the jco toolchain but with overhead. Evaluate per-language before committing to the pattern.
Extism vs. Spin architectural fit. Extism is better for retrofitting isolation into an existing MCP server: the host application is unchanged; plugins replace native function calls. Spin is better for greenfield deployments where the entire tool server is built as a WASM-native application; it provides a complete runtime with secrets management, triggers, and key-value stores at the cost of committing to the Spin deployment model.
WASM compatibility of tool dependencies. Tool implementations that rely on libraries using POSIX APIs unavailable in WASI (raw sockets, fork, exec, mmap with shared mappings) require porting or replacement. Libraries that do pure computation work without modification. Libraries that open network connections need to use WASI-compatible networking APIs, which the Extism PDK’s http_request provides for simple HTTP use cases.
Debugging complexity. Stack traces from within WASM plugins are less informative than native traces unless the .wasm binary includes DWARF debug symbols (produced by cargo build without --release). Production deployments use stripped release builds; debugging failing tools requires either structured error returns from the plugin or a staging environment with debug builds.
Failure Modes
Granting / as the WASI preopened directory. This gives the tool’s WASM module access to the entire host filesystem — the same access as the native process. The isolation is entirely defeated. The preopened directory must be the minimum required path, not a convenience root. Audit every preopened_dirs entry and verify that no entry is /, /var, or /home.
Passing full os.environ to the WASM module. env_vars=dict(os.environ) instead of an explicit per-tool dictionary means every tool sees every secret the process has: all API keys, all database URLs, all tokens. The WASM isolation provides memory safety but not credential isolation — the env is passed explicitly, so passing the wrong env explicitly defeats the point. Each tool’s env_vars must be constructed from the minimal required set, not copied from the parent process environment.
Not verifying WASM module signatures. An unsigned plugin deployment pipeline allows a supply chain compromise to substitute a malicious .wasm binary without detection. The malicious binary runs with the same capability grants as the legitimate one. Signature verification is not optional when the plugin code can influence capability-bearing operations like HTTP calls or file reads.
Extism plugin using extism_pdk::http_request without allowed_hosts configured. If the Extism manifest does not set allowed_hosts, the PDK’s HTTP host function allows requests to any host. A plugin can reach arbitrary external endpoints. allowed_hosts must be set to a non-empty list for any plugin that makes outbound HTTP calls; an empty list [] means no HTTP is allowed at all.
Using a single plugin instance across concurrent tool calls. Extism plugin instances are not thread-safe by default. Concurrent calls to the same instance cause data corruption in the plugin’s linear memory. Either use a per-call plugin instance (high instantiation cost without AOT caching) or a pool of pre-warmed instances with one-at-a-time acquisition per tool name. A pool of five instances per tool provides reasonable parallelism for typical MCP server call rates.
Accepting tool output without size or schema validation. A compromised plugin can return a 500 MiB JSON response. Without output size limits enforced at the host before deserialisation, this allocation occurs in the host process. Cap plugin output at a reasonable bound (1-10 MiB depending on the tool) and validate the returned JSON schema matches the expected tool output structure before passing it to the agent.