A read-only MCP server for a Beszel monitoring hub. Twelve tools let an agent answer questions about your servers — load, containers, services, disk health, alerts — without knowing anything about Beszel's API.
An independent project, not affiliated with or endorsed by the Beszel maintainers.
docker run -p 3000:3000 \
-e BESZEL_URL=https://beszel.example.com \
-e BESZEL_EMAIL=you@example.com \
-e BESZEL_PASSWORD=your-password \
-e MCP_AUTH_TOKEN=some-long-random-string \
ghcr.io/maxbth/beszel-mcpThe MCP endpoint is POST http://localhost:3000/mcp (Streamable HTTP).
For local clients that speak stdio:
docker run -i --rm \
-e BESZEL_URL=https://beszel.example.com \
-e BESZEL_EMAIL=you@example.com \
-e BESZEL_PASSWORD=your-password \
ghcr.io/maxbth/beszel-mcp --stdioWired into an MCP client — Claude Desktop, Claude Code, or anything else that launches a
subprocess. -i is required (the client talks to the container's stdin) and --rm keeps a
container from being left behind on every restart:
{
"mcpServers": {
"beszel": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "BESZEL_URL",
"-e", "BESZEL_EMAIL",
"-e", "BESZEL_PASSWORD",
"ghcr.io/maxbth/beszel-mcp", "--stdio"
],
"env": {
"BESZEL_URL": "https://beszel.example.com",
"BESZEL_EMAIL": "you@example.com",
"BESZEL_PASSWORD": "your-password"
}
}
}
}MCP_AUTH_TOKEN has no role here: there is no port to reach, so the only thing that can talk
to the server is the process that spawned it.
| Tag | Points at |
|---|---|
latest, 1.2.3, 1.2, 1 |
the most recent tagged release |
dev |
the current tip of main, rebuilt on every push |
dev moves. It is the right tag for trying unreleased changes and the wrong one
for anything you depend on, since the digest behind it changes without warning.
Pull a version tag for that.
| Tool | What it answers |
|---|---|
get_hub_info |
Can I reach the hub, and what is it running? |
list_systems |
What machines exist and how are they doing right now? |
get_system |
Full hardware and current state for one machine |
get_system_metrics |
How has this machine behaved over the last hour/day/week? |
list_containers |
What containers are running, and are any unhealthy? |
get_container_metrics |
Which container is eating the CPU? |
get_container_logs |
What is this container saying? |
list_services |
Which systemd units are failed? |
get_service_details |
Why is this unit unhappy? |
list_smart_devices |
Is any disk dying? |
list_alerts |
What alerts are configured, and which are firing? |
get_alert_history |
Is this problem recurring? |
Every tool is read-only. This server never writes to your hub.
| Variable | Default | Notes |
|---|---|---|
BESZEL_URL |
— | required |
BESZEL_EMAIL / BESZEL_PASSWORD |
— | required unless BESZEL_TOKEN |
BESZEL_TOKEN |
— | skips login |
BESZEL_SUPERUSER |
false |
authenticate against _superusers |
BESZEL_TIMEOUT_MS |
15000 |
per-request timeout |
MCP_TRANSPORT |
http |
http or stdio; --stdio overrides |
MCP_HOST |
127.0.0.1 |
the image sets 0.0.0.0 |
MCP_PORT |
3000 |
|
MCP_AUTH_TOKEN |
unset | when set, a matching bearer token is required |
MCP_ALLOWED_ORIGINS |
empty | comma-separated hostnames (not full origins), added to the localhost defaults on a localhost bind |
LOG_LEVEL |
info |
Set MCP_AUTH_TOKEN and every request to /mcp must carry a matching
Authorization: Bearer header. Leave it unset and the endpoint is open to anything that can
reach the port. There is no default value and no built-in fallback — an unset token means no
authentication, not a weak one.
1. Generate one. Any long random string works; these produce 32 bytes of base64:
openssl rand -base64 322. Give it to the server. Prefer a file over an inline -e, which lands in your shell
history and in docker inspect output:
echo "MCP_AUTH_TOKEN=$(openssl rand -base64 32)" >> .env
docker run -p 3000:3000 --env-file .env ghcr.io/maxbth/beszel-mcp3. Give it to the client. In an MCP client's server config:
{
"mcpServers": {
"beszel": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": {"Authorization": "Bearer PASTE_THE_TOKEN_HERE"}
}
}
}To check it by hand — the first should return 401, the second should not:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.com/mcp
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.com/mcp -H "Authorization: Bearer $MCP_AUTH_TOKEN"Comparison is constant-time, and the token is never written to a log line or an error message. To rotate it, change the value and restart: there is no session state, so nothing survives the restart and every client simply presents the new token on its next request.
MCP_AUTH_TOKEN protects /mcp only — see below for why /health is deliberately open.
GET /health returns 200 {"status":"ok"} and is reachable without a bearer token, from
outside the container, through a reverse proxy. It is exempt from the bearer check and from
the Host/Origin validation that guards /mcp, so an uptime monitor or tunnel — Pangolin,
Traefik, a Kubernetes liveness probe, the image's own HEALTHCHECK — can reach it without
being configured as a browser origin or preserving a particular Host header.
That exemption is safe because the route has nothing to protect: it returns no monitoring
data, no configuration and no credential, and it changes nothing. Host/Origin validation
exists to stop a malicious web page using a victim's browser to read data from a server on
their network — and there is no data here to read. Anyone who reaches /health learns only
that something is listening, which the TCP handshake already told them.
/mcp keeps every protection. A valid bearer token still does not buy past Origin
validation, and vice versa.
For Pangolin specifically, point the health check at /health on the same port you expose:
Health check path: /health
Expected status: 200
The image binds 0.0.0.0 so the container is reachable from outside itself. A wildcard
bind gets no automatic Host-header validation, so set MCP_AUTH_TOKEN — otherwise
anything that can reach the port can read every monitored machine, container and disk, and
fetch container logs. The server warns on stderr when you do not. Origin validation is
always on: on a wildcard bind with MCP_ALLOWED_ORIGINS empty, browser requests are rejected
outright and only non-browser clients (which send no Origin) get through.
On a localhost bind (127.0.0.1, localhost, ::1 — the default outside the image) the
localhost hostnames localhost, 127.0.0.1 and [::1] are allowed on top of whatever
MCP_ALLOWED_ORIGINS lists, so browser-hosted clients such as the MCP Inspector work with no
configuration. Non-localhost origins are still rejected.
Beszel scopes systems per user. If tools report no systems, the configured
account is probably not a member of them — either add it to each system, or run
the hub with SHARE_ALL_SYSTEMS=true.
get_container_logs additionally requires the hub to have container details
enabled (it is, unless the hub sets CONTAINER_DETAILS=false).
Built and tested against Beszel 0.19. An older hub still works: 0.19 moved hostname, kernel,
CPU model, core/thread counts, OS and podman out of systems.info into a system_details
collection, so list_systems joins it and falls back to the legacy blob when the collection
answers 404. Decoding likewise accepts both the current byte fields and the deprecated MB/s pairs.
Nothing needs configuring either way.
Requires Bun 1.3+.
bun install
bun test
bun run typecheck # Bun does not typecheck; this is what does
bun run lint:check
bun run devIssues and pull requests are welcome. Run those three commands before opening a PR — CI runs
exactly the same gate, plus a multi-arch Docker build, and fails on any lint warning. Tests are
colocated with what they test (src/config.ts / src/config.test.ts) and every tool is
read-only by contract, so a change that writes to the hub will not be accepted.
MIT © Maxime Bertheau