wasmer-sandbox-mcp
# wasmer-sandbox-mcp
A Wasmer DX exercise, shaped as a working product: **one Python package that ships two MCP
servers** — an Edge-deployed Postgres/observability server and a host-local `wasmer_sdk`
sandbox companion — built specifically to push on Wasmer's newest surface and write down
every place it pushed back.
**The primary deliverable is [`FEEDBACK.md`](FEEDBACK.md)** — 40 issue-ready entries, each
with versions, a reproducer, verbatim output, and a proposed fix. The code exists to
generate that ledger honestly; read the ledger first.
Live app: `https://wasmer-sandbox-mcp.wasmer.app/mcp`
## Why two servers
`wasmer_sdk` is a native host-only library. There is no `wasix_wasm32` wheel for it and no
remote backend, so **the sandbox SDK cannot run on Wasmer Edge** — the platform its own
vendor ships (see [`FEEDBACK.md` F-004](FEEDBACK.md)). Nothing in the announcement says
this. So the split is not a design flourish: it is the finding, made executable. The Edge
server gets everything WASIX can carry; the sandbox server stays on the host, and the two
attach to the same MCP client at the same time.
## Architecture
`wasmer_mcp/server_edge.py` builds an `mcp` 2.x `MCPServer` over streamable HTTP, stateless,
with JSON responses, bound explicitly to `0.0.0.0` on `$PORT` → `$FASTMCP_PORT` → `8000`.
`main.py` is the anybuild entrypoint and also exposes a module-level ASGI `app`.
`wasmer_mcp/db.py` holds a small `pg8000` layer over Edge Postgres — DSN assembled from the
five injected `DB_*` variables, TLS on, keyset pagination on the primary key.
`wasmer_mcp/models.py` is the boundary: every argument is parsed by pydantic before it
reaches SQL, so an invalid `area` is refused by the schema and never touches the database.
`wasmer_mcp/sandbox.py` owns a **two-slot warm sandbox pool** keyed by network policy
(network is a create-time parameter, so one slot cannot serve both), with a host-side
deadline around every run. `wasmer_mcp/server_local.py` serves those over stdio.
`app.yaml` carries the Postgres capability, the `fr-roub1` region pin, and two cron jobs.
## Tool surface
**Edge server** — `wasmer-edge-echo`, streamable HTTP at `/mcp`, needs the deployment.
| Tool | When to use |
|---|---|
| `get_server_info` | Check the deployed instance: database reachability and latency, region, versions, uptime. |
| `store_echo` | Save a note that outlives the conversation; returns its id. |
| `get_echo` | Fetch one note back by the id `store_echo` or `list_echoes` returned. |
| `list_echoes` | Browse notes newest-first, optionally narrowed by tag or text; paginate with `next_cursor`. |
| `record_feedback` | Report friction hit while using Wasmer, from inside a session, so it lands in the database. |
| `list_feedback` | Read those friction reports back, newest first. |
**Local companion** — `wasmer-sandbox`, stdio, needs `wasmer_sdk` on the host.
| Tool | When to use |
|---|---|
| `run_python` | Execute Python inside an isolated Wasmer sandbox and get `stdout`/`stderr`/`exit_code`. |
| `run_command` | Run any command available in the sandbox, with arguments. |
| `write_sandbox_file` | Put a text file into the sandbox before running code against it. |
| `read_sandbox_file` | Read a file the sandbox produced. |
| `list_sandbox_dir` | See what is in a sandbox directory, with kind and size. |
| `install_sandbox_package` | Install a Wasmer registry package into the sandbox and list the commands it adds. |
| `get_sandbox_info` | Check whether the slots are warm before a latency-sensitive call. |
| `reset_sandbox` | Throw away accumulated guest state and start clean. |
Sandbox state **persists between calls** by design — a file written by one tool is visible
to the next, and `reset_sandbox` is the escape hatch.
Both servers answer with the same grammar: success returns the payload object directly,
failure returns `{"error": {"code", "message", "hint"}}` where `hint` names the next action.
## Quickstart
### Local development
```bash
uv sync --extra local --extra dev
uv run --extra local wasmer-mcp-local # sandbox server over stdio
uv run python main.py # Edge server locally on 0.0.0.0:8000
uv run pytest -q # 42 offline tests, ~0.6 s, no network
```
### Wire it into Claude Code
`.mcp.json` is committed and works from a fresh clone:
```json
{
"mcpServers": {
"wasmer-sandbox": {
"type": "stdio",
"command": "uv",
"args": ["run", "--extra", "local", "wasmer-mcp-local"]
},
"wasmer-edge-echo": {
"type": "http",
"url": "https://wasmer-sandbox-mcp.wasmer.app/mcp"
}
}
}
```
The first sandbox call after a cold cache costs ~26 s; the server pre-warms in the
background and returns a `warming` error with a retry hint rather than blocking.
### Deploy
```bash
WASMER_BIN=/path/to/wasmer-7.x/bin/wasmer scripts/deploy.sh
```
Two things that script encodes, both learned the hard way:
- **The `wasmer` CLI must be 7.x.** anybuild 0.28.3 drives a 6.1.0 CLI through
`wasmer run --volume`, which it rejects, and 6.1.0's package upload dies on a bare
HTTP 500 from the registry. 7.4.0 works first try (F-019, F-020).
- **It builds from a staging copy of git-tracked files only.** anybuild's Python provider
copies the whole project directory into the image and ignores `.gitignore` — 213 MB from
a 100 KB project (F-021). Staging what git tracks also makes this path match
push-to-deploy, which by definition sees only tracked files.
anybuild installs to `~/.anybuild/bin/anybuild` (`curl -fsSL https://anybuild.run/install | sh`);
override with `ANYBUILD_BIN`.
### Live checks and the feedback exporter
```bash
WASMER_LIVE=1 uv run pytest -q tests/test_live.py # one Edge round-trip, one sandbox run
uv run python scripts/export_feedback.py # feedback rows as ledger-shaped blocks
```
The live checks are skipped by default — a live-by-default suite is unrunnable offline. The
exporter writes to stdout and deliberately does not edit `FEEDBACK.md`: table rows are lower
fidelity than curated entries, and a human decides what gets promoted.
## Push-to-deploy — USER ACTION
The repository is prepared so that a single push deploys, but the last mile requires an
authenticated browser session and a GitHub app authorization. **Do these in order:**
1. Create the GitHub repository (your choice of name and visibility) and add it as
`origin`.
2. Push `main`.
3. Open the Wasmer dashboard and select the `wasmer-sandbox-mcp` app.
4. Open the app's **Git settings** and choose **GitHub** as the repository provider.
5. Authorize Wasmer for the repository or organization if prompted.
6. Select the repository, then select branch **`main`** as the production branch.
7. Push a trivial commit and confirm a deployment starts on its own.
**Before that link exists, a push does nothing** — keep using `scripts/deploy.sh`. Once it
exists, `app.yaml` in the repository *extends* the app's configuration on every deployment,
so the region pin, the Postgres capability, and both cron jobs travel with the push.
## GitHub Actions (optional, currently untested)
`.github/workflows/deploy.yml` is the alternative path: `wasmerio/setup-wasmer@v3.1` pinned
to CLI 7.4.0, anybuild installed inline, then `scripts/deploy.sh` with `WASMER_TOKEN` from
repository secrets. It has **never run** — there is no repository yet — and it is included
for the comparison between the two paths, which is one of the things this exercise is for.
**Do not enable both paths.** A dashboard Git link and this workflow on the same branch
deploy the same commit twice. Pick one; if you pick the dashboard, delete the workflow.
To use it: add `WASMER_TOKEN` as a repository secret, and disconnect the dashboard Git link.
## Wasmer feature coverage
What this project actually exercised, and what it found. Ids are [`FEEDBACK.md`](FEEDBACK.md) entries.
| Feature | Exercised how | Status | Ledger |
|---|---|---|---|
| Edge: Python + uvicorn on WASIX | flat project, anybuild-built, `mcp` 2.x server bound to `0.0.0.0` | works, with workarounds | F-009, F-018, F-021 |
| Edge: streamable-HTTP MCP | `initialize`, `tools/list`, `tools/call` over HTTPS, stateless JSON | works | — |
| Edge Postgres | `pg8000` + TLS, five injected `DB_*` vars, `fr-roub1` pin, keyset pagination | works, after a driver swap | F-013, F-022, F-025, F-026 |
| Edge cron: `fetch` action | `prune-echoes` → `POST /jobs/prune?days=7` every 15 min, verified live | works | F-027 |
| Edge cron: `execute` action | `prune-echoes-exec` runs `python /app/jobs.py prune --days 7` every 15 min, verified live (SUCCESS, exit 0) — after an exit-code probe showed the job gets the app image and secrets but neither the app's working directory nor its `PYTHONPATH` | works with workaround | F-035, F-036, F-037, F-010, F-028 |
| anybuild | `Anybuild` generated and committed; build driven from a tracked-files staging copy | works, with workarounds | F-019, F-021, F-024, F-025 |
| `wasmer` CLI 6.1 → 7.4 | forced upgrade after three separate 6.1.0 failures | works on 7.4 only | F-017, F-019, F-020, F-012 |
| SDK: sandbox create + run | two-slot warm pool; `print(1+1)` → `2\n`; warm call 31 ms, cold create ~26 s | works | F-031 |
| SDK: timeouts | host-side deadline around every run, because the SDK's own `timeout=` never fires | works, with workaround | F-029, F-030 |
| SDK: filesystem | write / read / list, with guest paths normalised into the addressable root | works, with workaround | F-033, F-006 |
| SDK: `install_package` | `wasmer/edgejs@0.2.0` installs and advertises 107 commands; `node`/`npm`/`edge` cannot spawn | partly **broken** | F-034 |
| SDK: networking | create-time `NetworkPolicy`, one warm slot per policy | works | F-032 |
| Sandboxed SQL via `wasmer/pglite` | not attempted (stretch, cut) | not done | F-008 |
| Edge email (`enable_email` + `sendmail`) | not attempted — paid plan | not done | F-011 |
| GitHub push-to-deploy | repository prepared; dashboard link is the user action above | pending | F-003, F-040 |
| GitHub Actions deploy | workflow committed | untested | F-039 |
## Repository map
| Path | What |
|---|---|
| `main.py` | anybuild entrypoint and module-level ASGI app |
| `wasmer_mcp/` | `server_edge.py`, `server_local.py`, `db.py`, `models.py`, `sandbox.py` |
| `jobs.py` | the prune CLI, kept as the local runner and as the `execute`-job reproducer |
| `app.yaml`, `Anybuild` | Edge app configuration and the generated build definition |
| `scripts/deploy.sh` | staged deploy; `scripts/export_feedback.py` — database rows → ledger blocks |
| `tests/` | 42 offline tests plus two live checks behind `WASMER_LIVE=1` |
| `FEEDBACK.md` | **the deliverable** |
No secret is committed. `DB_*` are injected by the platform; `WASMER_TOKEN` lives only in
repository secrets or your shell.
TDQS
Scored across 8 tools
Most tools are sharply distinguished (file read/write/list, package install, reset, info). The only potential confusion is run_python vs run_command, since run_command could also invoke Python, but the descriptions draw a clear boundary by directing run_command to non-Python work.
All eight tools follow a consistent snake_case verb_noun pattern (run_, get_, write_, read_, list_, install_, reset_). The run_ versus sandbox_ prefixes correspond to a meaningful semantic split between execution and sandbox management, so there is no real inconsistency.
Eight tools is well within the ideal 3-15 range and maps cleanly to the server's purpose: two executors, three filesystem operations, package installation, state inspection, and reset. Each tool earns its place with no redundant entries.
The domain — isolated code execution with persistent sandbox state — is well covered: execute, read/write/list files, install packages, inspect, and reset. The only minor gap is the lack of a dedicated file removal tool, but agents can work around it via run_command('rm', ...) or reset_sandbox.