README
¶
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 the original klutchell/balenamcp (since transferred to
balena-io-experimental/balenamcp)
brought up to date with the current balena CLI and the current mark3labs/mcp-go.
Prerequisites
balenaCLI onPATH- An MCP-aware agent or IDE (Claude Code, Claude Desktop, Cursor, …)
- Go 1.25+ (only if you're building from source — pre-built binaries don't require it;
go.modpins the exact toolchain)
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 artifactchecksums.txt.sigstore.json— cosign signature overchecksums.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@v1.2.2 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_ASSET_DIR |
unset (filesystem tools disabled) | The single directory balenamcp may read from or write to on the host. Unset — the default — makes release-asset-download, release-asset-upload and ssh-key-add refuse to run at all; every other tool is a pure cloud call and is unaffected. When set, those tools take paths relative to this directory, and absolute paths, .. traversal and symlinks pointing outside it are rejected. Set it to a dedicated directory, not $HOME. |
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 —
mcpServersblock in~/.continue/config.yaml - Cline — VS Code settings under
cline.mcpServers - Goose —
goose configure(interactive) or~/.config/goose/config.yaml - LibreChat —
mcpServers:inlibrechat.yaml
If your client supports MCP via stdio, the binary will work — there's nothing balenamcp-specific about the wiring.
Tools
⚠️ Destructive tools — read this first
42 of the 64 tools change state on real devices or in balenaCloud. A reboot or
device-purgecan't be undone from inside the model. Every destructive tool is flagged withdestructiveHint: truein its MCP annotation, and Claude Desktop (and other compliant MCP clients) prompts you for confirmation before running them.
Tool Effect Reversible? device-rebootRemote reboot yes (device comes back up) device-restartRestart containers (no reboot; one device per call) yes device-stop-serviceStop a service container and leave it stopped yes ( device-start-service)device-start-serviceStart a stopped service container yes ( device-stop-service)device-shutdownRemote shutdown — manual power cycle to recover requires physical access device-purgeWipe /dataon the device (one device per call)no — data is gone device-rmPermanently remove a device from balenaCloud (passes --yes)no device-deactivateRelease a device from its fleet. On paid plans this charges a one-month fee; free-tier accounts are not charged (passes --yes)yes (the device re-registers when it comes online) device-moveMove a device to another fleet yes (move it back) device-registerRegister a new device with a fleet yes ( device-rm)device-os-updateHost OS update. A takeover target re-partitions the disk, erasing all data no — takeover updates cannot be rolled back device-sshRun an arbitrary command on the device (host OS or a service container) depends on the command run device-local-mode-setEnable/disable local mode (LAN dev access; suspends cloud updates) yes (toggle back) device-public-url-setExpose a device's service on a public, unauthenticated URL (or disable it) yes (disable again) device-renameRename a device yes (rename again) device-noteSet a device's note, replacing any existing note no — the previous note is not kept device-pinPin a device to a specific release yes ( device-track-fleetor re-pin)device-track-fleetDrop a device's pin and resume tracking the fleet's release yes ( device-pinagain)fleet-pinPin a fleet to a specific release yes ( fleet-track-latestor re-pin)fleet-track-latestDrop a fleet's pin and resume tracking the latest release yes ( fleet-pinagain)fleet-restartRestart containers on every device in a fleet yes fleet-purgeWipe /dataon every device in a fleetno — data is gone fleet-rmDelete a fleet (passes --yes)no fleet-createCreate a new fleet yes ( fleet-rm)fleet-renameRename a fleet yes (rename again) release-finalizePromote a draft release to final no — finals can't be un-finalized release-invalidateMark a release invalid so it won't auto-deploy yes ( release-validate)release-validateRe-validate a previously invalidated release yes ( release-invalidate)tag-setCreate or update a tag yes ( tag-rm)tag-rmRemove a tag yes ( tag-set)env-setSet/update an env or config variable yes ( env-rmorenv-setagain)env-rmDelete an env or config variable (passes --yes)yes ( env-setagain)env-renameChange an existing variable's value by numeric ID (despite the name) yes (set it back) organization-createCreate a new organization yes ( organization-rm)organization-renameRename an organization yes (rename again) organization-rmDelete an organization (passes --yes)no api-key-revokeRevoke API key(s) by ID no ssh-key-addRegister an SSH public key on the account yes ( ssh-key-rm)ssh-key-rmRemove an SSH key (passes --yes). Removing the wrong one can lock a user out of their devicesno — re-add the key from a local copy release-asset-uploadUpload a local file as a release asset yes ( release-asset-delete)release-asset-downloadWrites a file to the server's host filesystem yes (delete the file) release-asset-deleteDelete a release asset (passes --yes)no — the CLI documents this as impossible to undo Belt-and-suspenders gate: set
BALENAMCP_REQUIRE_CONFIRM=1and every destructive tool will refuse to run unless the call carriesconfirm: truein its arguments. Useful with MCP clients that don't honordestructiveHint.
Read-only
The remaining 22 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 |
ssh-key-info |
One SSH key by its numeric ID |
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 |
device-identify |
Blink a device's ACT LED so you can find the board on a shelf. Contacts the device but changes nothing — the LED stops on its own — so it needs no confirmation. |
device-public-url |
Print a device's public URL, or with status: true report whether it is enabled. Turn it on or off with device-public-url-set. |
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.
One device per call. The balena CLI accepts comma-separated lists on
several device commands and acts on every element, which would let a single
call reach an unbounded number of devices behind one confirmation — most
sharply device purge, which wipes /data irreversibly. balenamcp rejects any
value containing a comma on device-purge, device-restart, device-rm,
device-deactivate, device-move, device-start-service and
device-stop-service (on both the device and the service argument where
both exist), and asks the agent to loop instead.
Host filesystem access is opt-in. release-asset-download,
release-asset-upload and ssh-key-add are the only tools that read or write
local files, and they refuse to run unless BALENAMCP_ASSET_DIR names a
directory. All their
paths are relative to it. Argv-slice construction stops shell injection but
says nothing about path traversal, so the boundary is enforced explicitly:
absolute paths and Windows volume names are refused, the joined path must stay
inside the root after cleaning (rejecting ..), and it must still be inside
after symlink resolution (rejecting a symlink planted in the root that points
out of it). release-asset-delete touches no local files and works either way.
release-asset-download also requires output. The CLI would otherwise write
to the server process's working directory, outside the configured root. An
existing destination is an error unless overwrite: true, which stands in for
the CLI's interactive overwrite prompt, which cannot be answered over MCP.
device-move requires fleet and device-rm / device-deactivate always
pass --yes, so the server-side confirm gate (BALENAMCP_REQUIRE_CONFIRM)
is the only confirmation layer on device removal. device-register takes
device_type, which reaches the CLI as its camelCase --deviceType flag.
device-os-update requires version. Omitting it makes the CLI render an
interactive version picker, and --include-draft is not exposed at all —
upstream marks it mutually exclusive with --version, and when a version is
given the CLI already derives draft support from the version string. Candidate
versions come from os-versions (keyed by device type) and the device's
current version from device-info; the CLI has no non-interactive way to list
a specific device's update targets.
device-note requires its note text: the balena CLI documents that an omitted
note is read from stdin, but the v25.2.5 implementation instead writes an empty
string, silently clearing the note. The text also cannot start with -,
because it reaches the CLI as a positional argument and would be parsed as a
flag. device-rename requires new_name for the usual prompt-avoidance
reason.
api-key-revoke is the sole exception: its CLI command is inherently
list-shaped (balena api-key revoke 123,456) and its targets are credentials,
not devices, so constraining it to one ID per call would buy no safety.
fleet-create requires organization and type, even though the balena
CLI treats both as optional: the CLI falls back to an interactive dropdown when
either is missing and the account has more than one candidate, and an
interactive prompt over MCP cannot be answered — the call fails. The same reasoning makes
new_name mandatory on fleet-rename. Use organization-list and
device-type-list to discover valid values.
What balenamcp deliberately does not wrap
balenamcp wraps a subset of the balena CLI's ~90 commands. The gap is not an oversight
backlog — the commands below are out of scope by decision, for the reasons
given. This section exists so the question is settled once instead of being
re-litigated at every CLI bump; the balena-cli-parity review should treat a
command listed here as accounted for.
| Group | Commands | Why it stays out |
|---|---|---|
| Build / deploy pipeline | build, deploy, push, preload |
Need a local Docker daemon and a source tree. Long-running, and their streaming build output would blow past BALENAMCP_EXEC_TIMEOUT and flood the agent's context. |
| OS image / provisioning | os configure, os download, os initialize, device init, local configure, local flash, util available-drives |
Need physical media or a downloaded image on the server host; local flash writes raw bytes to a block device. The useful read-only half of this group, os versions, is wrapped. |
| Device config files | config generate, config inject, config read, config reconfigure, config write |
Operate on a mounted device or an image file, not the cloud API. Same host-hardware dependency as the group above. |
| Local network / SSH context | join, leave, device tunnel |
join/leave reconfigure the local machine's balena environment. device tunnel opens local TCP ports and runs in the foreground indefinitely — the same reason device logs --tail is not exposed. |
| Auth | login, logout |
balenamcp inherits the CLI's existing auth state (~/.balena/token) on purpose. Letting an agent change who the server is is a privilege-boundary problem, not a feature. |
| Third-party access | support |
Grants balena support agents access to your devices and fleets for a duration. Technically a plain cloud call, but delegating access to a third party is a human decision. |
| CLI host diagnostics | settings |
Prints local CLI configuration rather than balenaCloud state. Harmless and read-only — this is the cheapest entry here to reverse if a concrete need appears. |
| Credential minting | api-key generate |
Prints a live, long-lived balenaCloud API key to stdout, and balenamcp returns stdout straight to the MCP client, where it may be persisted, summarized, or forwarded. Redacting it would leave a tool whose entire output is withheld. Generate API keys in the balenaCloud dashboard instead. api-key-list and api-key-revoke are wrapped, so an agent can still audit and revoke. |
Standing rule for fleet-class variants. app create and block create have
the same shape as fleet create and produce the other two fleet classes. Only
the fleet form is wrapped: it is the class an agent operating a deployed
estate actually manages, and mirroring every variant triples the surface for no
new capability. Apply the same test to future variants — wrap the canonical
form, not its aliases.
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 .
The device-purge and device-shutdown sub-tests gate on additional
BALENA_LIVE_ALLOW_PURGE / BALENA_LIVE_ALLOW_SHUTDOWN opt-in env vars and
skip by default; the release-finalize sub-test always runs but exercises
only its error branch.
Layout:
main.go— entry point, flag parsing, stdio transportserver/setup.go— all tool definitionsserver/prompts.go— guided workflow promptsserver/resources.go—balena://resource documentsmain_test.go— in-process MCP client driving every tool in dry-run modelivetest_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-versionrecords the CLI version the wrapped command surface has been verified against. A RenovatecustomManager(defined in the shared renovate-config preset thatrenovate.jsonextends) watches thebalena-clinpm package and opens achore(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-paritycheck compares the flags and subcommands we pass against the current CLI surface.
Coverage and test reporting
The hard gate is the coverage (>= 80%) job in .github/workflows/ci.yml,
which enforces COVERAGE_MIN on a single Ubuntu cell and is a required check.
It runs with -coverpkg=./... deliberately: the tests live in package main
while most of the code lives in package server, so a per-package profile
reads ~46% against a real ~96%.
Codecov sits alongside it for
visibility only — PR comments, trend graphs and a per-surface breakdown of
tools / prompts / resources, configured in codecov.yml. It is never a
required check, and its uploads are non-blocking.
Test results are uploaded separately. The test job runs through
gotestsum to emit a JUnit
junit.xml per OS leg, tagged with a Codecov flag so Test Analytics can break
flaky and slow tests out per platform. That is the axis this repo varies on —
the BALENAMCP_ASSET_DIR path confinement resolves differently on macOS
(/var → /private/var) and Windows (8.3 short-name expansion), and both were
caught that way. Both uploads run under !cancelled(), so a failing run
still reports: gotestsum writes junit.xml before exiting non-zero, and a
red suite is exactly when the analytics matter.
Release flow
Releases are automated via release-please
- Commits to
mainuse Conventional Commits (feat:,fix:,perf:,chore:,docs:, etc.). The PR title gate enforces this on every PR. - release-please watches
mainand opens a "chore(main): release vX.Y.Z" PR that bumps.release-please-manifest.jsonand rewritesCHANGELOG.mdbased on the conventional-commit history since the last tag. - Merging that PR pushes a
vX.Y.Ztag. - goreleaser (in
release.yml) fires on the tag push: cross-compiles for 5 targets, generates CycloneDX SBOMs via syft, signschecksums.txtand 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 writes its startup line, config warnings, and (in -dry-run) one line per call to stderr |
Common errors
balena CLI error: exec: not found— thebalenabinary isn't onPATHfor whatever user the MCP host runs the server as. For desktop apps that's the GUI'sPATH, not your shell's. Add a symlink to/usr/local/bin/balenaor set the absolute path in the client's MCP config underenv/command.- Auth errors —
balena loginagain 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=1in the server's env (via the client's MCPenv:block) so the server itself refuses destructive calls withoutconfirm: truein the arguments. Documented above.
License
MIT
Documentation
¶
Overview ¶
Command balenamcp is a Model Context Protocol (MCP) server that wraps the balena CLI, letting MCP-aware AI assistants (Claude Desktop, Claude Code, Cursor, Cline, Continue, and others) drive balenaCloud through a structured tool interface instead of free-form shell invocation.
Usage ¶
balenamcp [-dry-run]
The server speaks MCP over stdio. It is normally launched by an MCP client via that client's server-configuration mechanism (for example, "claude mcp add balena /usr/local/bin/balenamcp"), not run directly.
The -dry-run flag swaps real command execution for stubbed responses that report the argv that would have been invoked. Useful for tool development and CI smoke tests without a live balena login.
Prerequisites ¶
The balena CLI must be installed and on PATH, and the invoking user must be authenticated (run "balena login" once). balenamcp shells out to the CLI for every operation; it does not talk to the balenaCloud API directly.
Tool surface ¶
The server exposes 64 tools covering fleets, devices, releases, tags, env vars, organizations, SSH keys, API keys and release assets, plus 11 guided workflow prompts and read-only balena:// resources. 22 tools carry the MCP readOnlyHint and are safe to invoke without confirmation; 42 carry the destructiveHint so compliant clients can prompt the user before running them. The per-tool inventory lives in the README and in package server's documentation — this file deliberately does not repeat the list, which goes stale with every addition.
Every tool's identifier arguments are flag-shape guarded: arguments beginning with "-" are rejected server-side to prevent argv injection where an agent passes "--help" or similar as a UUID. Free-form values (tag values, env values) are intentionally exempt. List-accepting device commands are constrained to one device per call, and the tools that touch the host filesystem are confined to the directory named by BALENAMCP_ASSET_DIR (disabled entirely when unset).
Configuration ¶
The balena CLI's own authentication state (typically ~/.balena/token) is inherited from the launching shell. Three environment variables tune the server itself:
- BALENAMCP_EXEC_TIMEOUT — wall-clock cap in seconds for any single balena CLI subprocess (default 60).
- BALENAMCP_REQUIRE_CONFIRM — when truthy, every destructive tool refuses to run unless the call carries confirm:true in its arguments.
- BALENAMCP_ASSET_DIR — the single directory the release-asset download/upload and ssh-key-add tools may read or write; unset (the default) disables those tools entirely.
See the README's environment-variable table for the full semantics.
Verifying a release ¶
Release archives are signed via Sigstore cosign keyless signing and published with CycloneDX SBOMs. See the project README's "Install" section for the cosign verify-blob invocation.