MCP server for isolated code execution via go-microvm microVMs. Go + mcp-go, Streamable HTTP transport. Module: github.com/stacklok/waggle.
task build # Build self-contained binary with embedded go-microvm runtime
task test # go test -v -race ./...
task lint # golangci-lint run ./...
task verify # fmt + lint + test (CI gate)
task tidy # go mod tidy
task gen # go generate ./... (mocks)
task run # Build and run serverRun a single test: go test -v -race -run TestName ./pkg/path/to/package
pkg/domain/— Pure types and interfaces, no external deps.environment/is the aggregate root with a state machine (Creating→Running→Destroying→Destroyed|Error)pkg/service/— Orchestration:EnvironmentService,ExecutionService,FilesystemServicepkg/infra/— Adapters:vm/(go-microvm),ssh/(executor+filesystem),store/(in-memory repo)pkg/mcp/— 8 MCP tool definitions + handlers + server assemblypkg/cleanup/— Background reaper for expired environments- Entry point:
cmd/waggle/main.gowires everything with DI, handles signals
- go-microvm is a tagged dependency (v0.0.22):
task buildembeds go-microvm-runner, libkrun, and libkrunfw into the binary (downloaded viatask fetch-runtime/task fetch-firmware, verified with sha256sums). Usebuild-dev-systemfor the system libkrun-devel path. - Image cache and log level:
WAGGLE_IMAGE_CACHE_DIR(default~/.cache/waggle/images/) enables shared OCI image cache with layer-level caching and COW rootfs cloning.WAGGLE_IMAGE_CACHE_MAX_AGE(default168h/7d) controls eviction.WAGGLE_LOG_LEVEL(0-5, default 0) sets libkrun verbosity; levels > 3 leak hypervisor internals. - Layered images: Runtime images (python/node/shell) inherit from
images/base/viaARG BASE_IMAGE. Build base first:task build-image-base. All runtime image tasks depend on it automatically. - MCP error handling has two paths: Return
mcp.NewToolResultError("msg"), nilfor user-facing errors (bad input, not found). Returnnil, erronly for internal server failures. Mixing these up breaks the MCP protocol. - Code execution uses temp files, not
-c: Multi-line code is written to/tmp/waggle_<uuid>.<ext>in the VM via heredoc, executed, then cleaned up. Usingpython3 -cornode -ebreaks on complex code. - Shell escaping is mandatory: Always use
go-microvm/ssh.ShellEscape()for any user-provided string passed to SSH commands. Missing this is a command injection vulnerability. - MemoryStore returns copies: Both
Save()andFindByID()copy the struct to prevent aliasing bugs. Mutating a returned*Environmentdoes not affect the store — you must callSave()again. - Port allocator verifies with net.Listen: It doesn't just track allocations — it probes each port. Tests must call
portAlloc.SetListenCheck(func(_ uint16) error { return nil })to skip real binding. - SPDX headers on every file: Every
.goand.yamlfile needs bothSPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.andSPDX-License-Identifier: Apache-2.0. Linting will fail without them.
log/slogexclusively — usefmt.Errorf("context: %w", err)for error wrapping- Table-driven tests with
t.Parallel()at both test and subtest level - Every package has a
doc.gowith SPDX header - Domain interfaces defined in
pkg/domain/, implemented inpkg/infra/ - IMPORTANT: Stage specific files only. Use
git add file1 file2, nevergit add -A
After any change:
task verify # Format, lint, test — the full CI gateWhen tests fail, fix the implementation, not the tests.