BalenaMCP

A Model Context Protocol server that wraps
the balena CLI so MCP-aware agents
(Claude Code, Claude Desktop, Cursor, Continue, Cline, Goose, etc.) can list
fleets, inspect devices, manage tags and env vars, pin releases, reboot
devices, and more.
It exposes all three MCP primitives: tools (direct balena actions),
prompts (guided multi-step workflows like canary rollouts and device
diagnosis), and resources (read-only fleet/device/release state you attach
as context). See the Tools, Prompts, and
Resources sections below.
This is a personal fork of klutchell/balenamcp
brought up to date with the current balena CLI and the current mark3labs/mcp-go.
Prerequisites
balena CLI on PATH
- An MCP-aware agent or IDE (Claude Code, Claude Desktop, Cursor, …)
- Go 1.23+ (only if you're building from source — pre-built binaries don't require it)
Install
Three options, pick whichever fits.
1. Pre-built binary from GitHub Releases (recommended)
Grab the archive for your OS/arch from
Releases:
| OS |
Arch |
Archive |
| Linux |
x86_64 |
balenamcp_<version>_Linux_x86_64.tar.gz |
| Linux |
arm64 |
balenamcp_<version>_Linux_arm64.tar.gz |
| macOS |
x86_64 (Intel) |
balenamcp_<version>_Darwin_x86_64.tar.gz |
| macOS |
arm64 (Apple Silicon) |
balenamcp_<version>_Darwin_arm64.tar.gz |
| Windows |
x86_64 |
balenamcp_<version>_Windows_x86_64.zip |
Each release also publishes:
checksums.txt — SHA-256 of every artifact
checksums.txt.sigstore.json — cosign signature over checksums.txt
<archive>.sbom.cdx.json — CycloneDX Software Bill of Materials per archive
<archive>.sbom.cdx.json.sigstore.json — cosign signature over each SBOM
Verifying a download (recommended)
The release is signed with cosign
using Sigstore keyless signing — no public-key juggling needed. Install
cosign, then:
# 1. Verify the signature on checksums.txt
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp \
'https://github.com/schubydoo/balenamcp/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txt
# 2. Verify your archive against the (now-trusted) checksums file
sha256sum --check checksums.txt --ignore-missing
If both commands succeed, you've cryptographically verified that the
archive was built by this repo's release workflow (not a typo-squatter,
not tampered with in transit).
Extract and install
tar -xzf balenamcp_<version>_Linux_x86_64.tar.gz
sudo install balenamcp /usr/local/bin/
balenamcp --help # or just `balenamcp` to start serving over stdio
2. go install (Go developers)
go install github.com/schubydoo/balenamcp@latest
Resolves to the highest semver tag, builds locally, and drops the binary
in $GOBIN (default $GOPATH/bin or ~/go/bin). For docs and package
info: https://pkg.go.dev/github.com/schubydoo/balenamcp.
@latest follows the most recent release; pin a specific version with
go install github.com/schubydoo/balenamcp@v0.1.0 if you'd rather not
auto-update.
3. Build from source
git clone https://github.com/schubydoo/balenamcp.git
cd balenamcp
go mod download
go build -o bin/balenamcp
Cross-compile examples:
GOOS=linux GOARCH=amd64 go build -o bin/balenamcp-linux-amd64
GOOS=windows GOARCH=amd64 go build -o bin/balenamcp-windows-amd64.exe
GOOS=darwin GOARCH=arm64 go build -o bin/balenamcp-darwin-arm64
For a versioned source build (matches release builds):
go build -ldflags='-s -w -X github.com/schubydoo/balenamcp/server.Version=v0.1.0' \
-trimpath -o bin/balenamcp .
Without the -X ldflag, the server reports dev as its version.
A -dry-run flag is available — the server prints the balena command it
would run instead of executing it. Useful for testing/debugging.
Environment variables
| Variable |
Default |
Purpose |
BALENAMCP_EXEC_TIMEOUT |
60 (seconds) |
Wall-clock cap on any single balena CLI subprocess. Prevents device-logs --tail and similar long-running commands from blocking the MCP transport indefinitely. Set to a higher integer for slow networks; the server logs a warning and falls back to default if the value is non-positive or non-numeric. |
BALENAMCP_REQUIRE_CONFIRM |
unset (off) |
When set to 1/true, every destructive tool refuses to run unless the call carries confirm: true in its arguments. A belt-and-suspenders safety net for MCP clients that ignore the destructiveHint annotation. Off by default — Claude Desktop and other compliant clients already prompt before invoking destructive tools, so the gate is redundant there. |
Authenticate
Before using any tool that touches balenaCloud, log in once on the host:
balena login
The MCP server inherits that session by shelling out to the same balena
binary.
MCP client setup
The server speaks stdio JSON-RPC — any MCP-compliant client wires it up the
same way: point at the binary, optionally pass args, restart the client.
Specifics below.
Claude Code
CLI (recommended):
claude mcp add balena /absolute/path/to/balenamcp
That edits ~/.claude.json for you. Use claude mcp add balena --scope project ...
to scope it to the current repo (.mcp.json in repo root) instead.
Or edit ~/.claude.json by hand:
{
"mcpServers": {
"balena": {
"command": "/absolute/path/to/balenamcp",
"args": []
}
}
}
The tools appear in the next Claude Code session. Run /mcp to confirm.
Claude Desktop
Add to claude_desktop_config.json:
| OS |
Path |
| macOS |
~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows |
%APPDATA%\Claude\claude_desktop_config.json |
| Linux* |
~/.config/Claude/claude_desktop_config.json |
* Claude Desktop on Linux is unofficial; the path mirrors the XDG location
that community builds use.
{
"mcpServers": {
"balena": {
"command": "/absolute/path/to/balenamcp",
"args": []
}
}
}
Restart Claude Desktop. The tools appear under the balena server.
Other MCP clients
Setup is similar — point the client at the binary as a stdio MCP server.
Consult each tool's docs for the exact config file / command:
- Cursor — Settings → MCP → Add server, or
~/.cursor/mcp.json
- Continue —
mcpServers block in ~/.continue/config.yaml
- Cline — VS Code settings under
cline.mcpServers
- Goose —
goose configure (interactive) or ~/.config/goose/config.yaml
- LibreChat —
mcpServers: in librechat.yaml
If your client supports MCP via stdio, the binary will work — there's
nothing balenamcp-specific about the wiring.
25 of the 44 tools change state on real devices or in balenaCloud. A
reboot or device-purge can't be undone from inside the model. Every
destructive tool is flagged with destructiveHint: true in its MCP
annotation, and Claude Desktop (and other compliant MCP clients) prompts
you for confirmation before running them.
| Tool |
Effect |
Reversible? |
device-reboot |
Remote reboot |
yes (device comes back up) |
device-restart |
Restart containers (no reboot) |
yes |
device-shutdown |
Remote shutdown — manual power cycle to recover |
requires physical access |
device-purge |
Wipe /data on the device |
no — data is gone |
device-ssh |
Run an arbitrary command on the device (host OS or a service container) |
depends on the command run |
device-local-mode-set |
Enable/disable local mode (LAN dev access; suspends cloud updates) |
yes (toggle back) |
device-pin |
Pin a device to a specific release |
yes (device-track-fleet or re-pin) |
device-track-fleet |
Drop a device's pin and resume tracking the fleet's release |
yes (device-pin again) |
fleet-pin |
Pin a fleet to a specific release |
yes (fleet-track-latest or re-pin) |
fleet-track-latest |
Drop a fleet's pin and resume tracking the latest release |
yes (fleet-pin again) |
fleet-restart |
Restart containers on every device in a fleet |
yes |
fleet-purge |
Wipe /data on every device in a fleet |
no — data is gone |
fleet-rm |
Delete a fleet (passes --yes) |
no |
release-finalize |
Promote a draft release to final |
no — finals can't be un-finalized |
release-invalidate |
Mark a release invalid so it won't auto-deploy |
yes (release-validate) |
release-validate |
Re-validate a previously invalidated release |
yes (release-invalidate) |
tag-set |
Create or update a tag |
yes (tag-rm) |
tag-rm |
Remove a tag |
yes (tag-set) |
env-set |
Set/update an env or config variable |
yes (env-rm or env-set again) |
env-rm |
Delete an env or config variable (needs yes: true to bypass the CLI's confirm prompt) |
yes (env-set again) |
env-rename |
Change an existing variable's value by numeric ID (despite the name) |
yes (set it back) |
organization-create |
Create a new organization |
yes (organization-rm) |
organization-rename |
Rename an organization |
yes (rename again) |
organization-rm |
Delete an organization (passes --yes) |
no |
api-key-revoke |
Revoke API key(s) by ID |
no |
Belt-and-suspenders gate: set BALENAMCP_REQUIRE_CONFIRM=1 and every
destructive tool will refuse to run unless the call carries
confirm: true in its arguments. Useful with MCP clients that don't
honor destructiveHint.
Read-only
The remaining 19 tools are read-only — they shell out to balena with no
state change. Safe to call without confirmation.
| Tool |
Purpose |
version |
balena CLI version |
whoami |
Current user / org / device session info |
fleet-list |
List all accessible fleets |
fleet-info |
Detailed info for one fleet |
device-list |
List devices, optionally filtered by fleet |
device-info |
Detailed info for one device |
device-logs |
Recent historical logs from a device, optionally per-service. Streaming (--tail) is not supported over MCP — run the balena CLI directly for continuous monitoring. Note: this tool's identifier arg is named device (accepts UUID, IP, or .local address) while every other device-* tool uses uuid. Kept this way to match the broader argument shape balena's CLI accepts here. |
device-type-list |
Supported balena device types |
release-list |
Releases of a fleet |
release-info |
Metadata or composition of one release |
release-asset-list |
Binary assets attached to a release |
tag-list |
Tags on a fleet or device or release |
env-list |
Env/config variables on a fleet or device, optionally per-service |
os-versions |
Available balenaOS versions for a device type |
organization-list |
Organizations the user belongs to |
ssh-key-list |
SSH keys registered in balenaCloud |
api-key-list |
balenaCloud API keys |
device-detect |
Scan the local network (LAN) for balenaOS devices |
device-local-mode-get |
Report whether local mode is enabled on a device |
Argument constraints
tag-list / tag-set / tag-rm require exactly one of
fleet / device / release. env-list / env-set require exactly one
of fleet / device.
Prompts
Beyond tools, the server exposes MCP prompts — guided, multi-step
workflows you invoke from your client (in Claude Desktop they appear in the
prompt/slash picker). A prompt runs no balena commands itself; it returns a
runbook that tells the model which tools to call and in what order, encoding
the sequencing and safety ordering an experienced operator would follow.
Destructive steps still go through the same destructiveHint /
BALENAMCP_REQUIRE_CONFIRM guards as any other tool call.
| Prompt |
Arguments |
What it walks the model through |
diagnose-device |
uuid |
Pull status, logs, env, tags, and pin state for one device, then summarize a health verdict and likely root cause. Read-only. |
fleet-health-report |
fleet |
Tally device status across a fleet, compare each device against the fleet's target release, flag what needs attention. Read-only. |
safe-release-rollout |
fleet, release |
Canary-first rollout: record the rollback target, pin one device, verify it, then roll out fleet-wide — pausing for approval before each state change. Recovers via re-pin, release-invalidate, or fleet-track-latest. |
rollback-device |
uuid |
Identify a previously known-good release and roll a single device back to it, after confirming the target; can release-invalidate the bad release. |
audit-config |
fleet |
Compare device-level env/config variables against fleet defaults; surface drift, secret-shaped values (never echoed), and orphaned overrides. Read-only. |
compare-releases |
release_a, release_b |
Diff two releases: per-service image-size deltas (how much bigger/smaller), composition changes, and asset differences. Read-only. |
replicate-config |
source, target |
Copy env/config variables from one fleet/device to another, with a masked plan and an approval step before any write. |
bulk-tag |
fleet, key, value (optional) |
Apply a tag to many devices in a fleet at once, with an approval step before any write. |
deep-diagnose-device |
uuid |
Host-level diagnosis over device-ssh: memory, disk, load, failed services, and container state, then a resource-risk summary. Inspection commands only. |
prepare-local-dev |
device (optional) |
Enable local mode for LAN development — discovering the device with device-detect first if not given — with an approval step. |
rotate-api-keys |
fleet (optional) |
Review API keys and revoke the stale ones via api-key-revoke, with explicit approval before anything is revoked. |
Resources
The server also exposes read-only balena state as MCP resources under the
balena:// URI scheme. Where a tool is a single CLI call the model invokes, a
resource is context you attach to the conversation — and each one composes
several CLI calls into one JSON document, so a single read gives the model a
coherent picture instead of forcing several separate tool calls. Composition
degrades gracefully: if a sub-call fails (e.g. logs for an offline device) the
document still returns the sections that succeeded and records the rest under
an "errors" object with "partial": true.
| Resource URI |
Type |
Aggregates |
balena://account |
static |
whoami + organizations |
balena://account/keys |
static |
registered SSH public keys + API key names (no secrets) |
balena://gotchas |
static |
known balena CLI foot-guns + correct invocations (SSH one-shot, log streaming limits) |
balena://fleets |
static |
all accessible fleets |
balena://device-types |
static |
supported device types |
balena://device/{uuid} |
template |
device status + recent logs + env/config + tags |
balena://fleet/{org}/{fleet} |
template |
fleet metadata + devices + env/config + releases |
balena://fleet/{org}/{fleet}/releases |
template |
the fleet's release history |
balena://release/{id} |
template |
release metadata + docker-compose composition + assets |
balena://os-versions/{type} |
template |
available balenaOS versions (stable + ESR + draft) for a device type |
Fleet slugs are org/fleet, so the fleet templates take the two parts as
separate path segments (e.g. balena://fleet/myorg/myfleet).
Development
go build -o bin/balenamcp # build
go test ./... # unit tests (in-process MCP client, dry-run)
For a live end-to-end sweep against real balenaCloud (requires balena login
and a sacrificial device):
BALENA_LIVE_FLEET=myorg/myfleet \
BALENA_LIVE_DEVICE=<uuid> \
BALENA_LIVE_RELEASE=<commit> \
BALENA_LIVE_RELEASE_ALT=<other-commit> \
go test -tags=integration -v -count=1 -run TestLiveSweep .
Irreversible sub-tests (device-purge, device-shutdown, release-finalize)
gate on additional BALENA_LIVE_ALLOW_* opt-in env vars and skip by default.
Layout:
main.go — entry point, flag parsing, stdio transport
server/setup.go — all tool definitions
main_test.go — in-process MCP client driving every tool in dry-run mode
livetest_test.go — build-tagged (integration) end-to-end sweep against
real balenaCloud, opt-in via env vars
Keeping up with the balena CLI
balenamcp shells out to whatever balena is on PATH, so upstream CLI changes
(a renamed flag, a moved subcommand) can drift away from the argv our tools
construct. The dry-run tests assert what we send, not what the current CLI
accepts, so drift is caught two ways:
.balena-cli-version records the CLI version the wrapped command surface
has been verified against. A Renovate customManager (see renovate.json)
watches the balena-cli npm package and opens a chore(deps) PR — with the
upstream changelog attached — whenever a newer version ships. Review that PR
for parameter drift and merge it only once the new version is confirmed
clean; the merge is the record that it was reviewed.
- For the actual review, the
balena-cli-parity check compares the flags
and subcommands we pass against the current CLI surface.
Release flow
Releases are automated via release-please
- Commits to
main use Conventional Commits
(feat:, fix:, perf:, chore:, docs:, etc.). The PR title gate
enforces this on every PR.
- release-please watches
main and opens a "chore(main): release vX.Y.Z"
PR that bumps .release-please-manifest.json and rewrites CHANGELOG.md
based on the conventional-commit history since the last tag.
- Merging that PR pushes a
vX.Y.Z tag.
- goreleaser (in
release.yml) fires on the tag push: cross-compiles
for 5 targets, generates CycloneDX SBOMs via syft, signs checksums.txt
and SBOMs with cosign keyless, uploads everything to the GitHub Release
release-please already created.
End-to-end, a release looks like:
$ git commit -m "feat: add foo tool" # conventional commit
$ git push # to a branch + PR + merge
# ... release-please opens release PR ...
$ # merge the release PR ...
$ # ... goreleaser publishes binaries + SBOMs + signatures to GH Releases
Troubleshooting
Where to find the server's logs
Logs land wherever the MCP host writes them:
| Client |
Path |
| Claude Desktop, macOS |
~/Library/Logs/Claude/mcp-server-balena.log |
| Claude Desktop, Windows |
%APPDATA%\Claude\logs\mcp-server-balena.log |
| Claude Desktop, Linux |
~/.config/Claude/logs/mcp-server-balena.log |
| Claude Code |
claude --mcp-debug enables verbose MCP logging to the current session; persistent logs live in ~/.claude/projects/<encoded-cwd>/ |
| Cursor / Continue / others |
Check each client's own log dir; the server itself writes only its startup line to stderr |
Common errors
balena CLI error: exec: not found — the balena binary isn't on
PATH for whatever user the MCP host runs the server as. For desktop
apps that's the GUI's PATH, not your shell's. Add a symlink to
/usr/local/bin/balena or set the absolute path in the client's MCP
config under env / command.
- Auth errors —
balena login again on the host where the server runs.
We need to slow down your requests temporarily. — balenaCloud
rate-limits fast sequential calls. The server doesn't queue or back off;
it surfaces the message as the tool result. Wait a few seconds and retry,
or pace the agent's calls. Most noticeable when sweeping many tools in a
row (e.g. validation runs).
- Tool annotated as destructive but the client didn't prompt — set
BALENAMCP_REQUIRE_CONFIRM=1 in the server's env (via the client's MCP
env: block) so the server itself refuses destructive calls without
confirm: true in the arguments. Documented above.
License
MIT