Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ install. Both are covered in [Installation](docs/installation.md).
|-------|--------------|
| [Installation](docs/installation.md) | Requirements, Docker, systemd install, config wizard, reverse proxy, updates |
| [Configuration](docs/configuration.md) | The two config files, every YAML key that matters, where to run the server |
| [Tools](docs/tools.md) | The 44 MCP tools, grouped by module |
| [Tools](docs/tools.md) | The 46 MCP tools, grouped by module |
| [Updates](docs/updates.md) | The update notice, the self-update tools, and how to turn both off |
| [Client setup](docs/clients.md) | Assistant, ChatGPT, Gemini, Mistral, VS Code, Cursor, OpenCode |
| [Security](docs/security.md) | What to review before approving a tool call, token handling, TOTP hygiene |
| [Dashboard](docs/dashboard.md) | The optional `/app/*` web panel: login, API tokens, Gemini chat |
Expand Down
14 changes: 14 additions & 0 deletions beaconmcp.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,20 @@ features:
public_url: https://mcp.example.com # used to generate MCP URLs in the UI
mcp_mode: local # "local" (default) or "remote"

# Update notifications. When enabled, the server periodically compares
# this checkout against the upstream default branch and shows a notice
# in the dashboard (signed-in operators only), with instructions matched
# to how BeaconMCP was installed here. Also exposes the
# beaconmcp_check_update / beaconmcp_self_update MCP tools.
updates:
# Set to false on an air-gapped or change-controlled deployment: the
# server then never contacts the git remote at all.
enabled: true
# Set to false to keep the notification but forbid applying it from
# the dashboard or over MCP -- appropriate when updates go through a
# deployment pipeline. Manual instructions are still shown.
allow_self_update: true

# Free-form infrastructure context exposed as an MCP resource. Edit freely:
# the LLM reads this to understand your topology, naming conventions, and
# operational notes.
Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,12 @@ Tailscale IP, a VPN address, a bastion.

Everything else about the panel — enabling it, the tokens page, cost tracking, the confirmation
modal — is in [dashboard.md](dashboard.md).

## Updates

| Key | Notes |
|-----|-------|
| `features.updates.enabled` | Default `true`. Compares this checkout against the upstream default branch and shows a notice to signed-in operators. Set to `false` on an air-gapped or change-controlled box: the server then never contacts the git remote, and the `beaconmcp_*_update` MCP tools are not registered. |
| `features.updates.allow_self_update` | Default `true`. Set to `false` to keep the notification but forbid applying it from the dashboard or over MCP — the right setting when deploys go through a pipeline. Manual instructions are still shown. |

See [updates.md](updates.md) for the notice, the MCP tools, and what the self-update does.
18 changes: 16 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# MCP tools

44 tools across six modules. The infrastructure modules are only registered when the matching
46 tools across seven modules. The infrastructure modules are only registered when the matching
capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from
Proxmox or BMC. `security_end_session` is always registered, whatever the topology.
Proxmox or BMC. `security_end_session` and the two maintenance tools are always registered,
whatever the topology.

Long-running commands (`proxmox_run`, `ssh_run`) are synchronous by default. Pass `wait=False` to
start one in the background and get an `exec_id` back, then call the same tool with `exec_id=` to
Expand Down Expand Up @@ -92,3 +93,16 @@ to that device.
| Tool | Description |
|------|-------------|
| `security_end_session` | Revoke the bearer token used for the current request, ~8 s after responding. Call it as the last step of a task to shrink the window in which a stolen token can be replayed — never mid-task, or the next call gets a 401. |

## Maintenance (2)

Registered on every deployment shape, unless `features.updates.enabled` is `false`.

| Tool | Description |
|------|-------------|
| `beaconmcp_check_update` | Read-only. Reports the running version, how many commits this install is behind upstream, the changelog, any `.env` / `beaconmcp.yaml` settings the new revision knows about that this install has not set, and the exact commands that would update *this* install (git checkout, pip install and container each get different ones). Cached for a few hours. |
| `beaconmcp_self_update` | Applies the update: `git pull --ff-only` → reinstall the package and its dependencies → **validate the config against the new code** → schedule a restart. Requires `confirm=True`. Refuses on a dirty checkout or a non-git install. If the new revision cannot load the current config, everything is rolled back and nothing restarts. Hidden when `features.updates.allow_self_update` is `false`. |

The restart is deferred a few seconds so the tool result reaches the caller before the process dies.
Show the user `beaconmcp_check_update` output — especially any new configuration — and get an
explicit go-ahead before calling `beaconmcp_self_update`.
121 changes: 121 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Updates

BeaconMCP publishes no releases and no PyPI package: the canonical install is a `git clone` at
`/opt/beaconmcp` with a venv and a systemd unit. So "is there an update?" means **is this checkout
behind the upstream default branch?**

The server answers that question itself, tells signed-in operators, and can apply the update.

## The notice

Signed in to the dashboard, a card appears bottom-right on any `/app/*` page when the checkout is
behind:

- how far behind, and the revision range (`9f496cb → abc1234`);
- the last few commit subjects, and a link to the full diff on GitHub;
- **any configuration the new revision knows about that you have not set** — new `.env` variables,
new `beaconmcp.yaml` settings;
- the exact commands to update *this* install;
- an **Update now** button, when an automatic update is possible.

Dismissing it hides that specific revision; the card returns when a newer one lands.

On the **"You're signed in"** screen — the moment the session is created, one click before the panel
— you get a one-line mention instead of the full card. That screen has a single primary action, and
on a narrow viewport a bottom-anchored card this tall would sit right on top of it. The card itself
opts out of the auth pages entirely and shows on the landing page.

The endpoint behind it (`GET /app/api/update`) requires a live session and returns `401` otherwise.
That is deliberate: the exact revision a server runs is free reconnaissance for anyone who has not
authenticated, and the card is only ever rendered to someone signed in.

## Instructions match your install

Detection is not a guess about how you *should* have installed it:

| Detected | What you are told |
|----------|-------------------|
| git checkout | `cd <root>` → `git pull --ff-only` → `<venv>/bin/pip install -e .` (the real venv path, when there is one) → `systemctl restart beaconmcp` if a unit file exists |
| container | `docker compose pull` → `docker compose up -d` |
| pip distribution | `pip install --upgrade 'beaconmcp @ git+https://github.com/Showdown76py/BeaconMCP.git'` |
| unknown | Re-run `deploy/install.sh` from a checkout |

## MCP tools

Two tools, registered on every deployment shape:

- **`beaconmcp_check_update`** — read-only. Version, commits behind, changelog, new configuration,
and the commands for this install. Cached for a few hours.
- **`beaconmcp_self_update`** — applies it. Requires `confirm=True`.

Ask your assistant to "check whether BeaconMCP has an update" and it will read the changelog and any
new settings back to you before touching anything.

## What the self-update actually does

In order, stopping at the first failure:

1. **Preflight** — refuses on a non-git install, and refuses when the checkout has uncommitted
changes. Local edits are never discarded.
2. **`git pull --ff-only`** — a fast-forward or nothing. No merges, no rebases.
3. **Reinstall** — `pip install -e .` in the detected venv, so new or bumped dependencies land.
4. **Validate the config** — runs `beaconmcp validate-config` in a subprocess, so the *new* code
parses your *actual* configuration.
5. **Restart** — `systemctl restart`, deferred a few seconds so the response reaches you first.

Step 4 is a hard gate, and it is the reason this is safe to run unattended. If the new revision
cannot load your config — a setting was renamed, a new one is now required — the checkout is reset
to exactly where it started, dependencies are restored, **nothing is restarted**, and the error from
the validator is handed back to you. An update that bricks the server is worse than no update.

Only one update runs at a time. The dashboard button and the MCP tool reach the same code, and two
`git pull` / `pip install` runs in one checkout would fight over `index.lock`. A second caller is
told one is already in progress rather than queued behind a pip that may take minutes.

### From the dashboard

The **Update now** button asks for a fresh 2FA code before it runs. Pulling code and restarting the
process is the most privileged thing the panel can do, so a session alone is not enough — same bar
as minting an API token.

## Turning it off

```yaml
features:
updates:
enabled: true # false: never contact the remote, no tools, no notice
allow_self_update: true # false: keep the notice, forbid applying it
```

`enabled: false` is the air-gap switch. `allow_self_update: false` is for deployments where updates
go through a pipeline: operators still see that one is available, with instructions, but neither the
dashboard button nor the MCP tool exists.

## Stale assets after an update

A server that can update itself must not leave browsers running the previous release's JavaScript.
Starlette serves static files with `ETag`/`Last-Modified` but no `Cache-Control`, which puts
browsers on *heuristic* freshness: a file untouched for weeks is reused for a long time without ever
revalidating.

Asset URLs therefore carry a fingerprint of the bundle (`app.css?v=6a69fedf`), recomputed at each
start from the newest mtime in the static directory — which a `git pull` bumps. New bytes mean a new
URL, so no cache can satisfy the request from an old entry:

| Response | `Cache-Control` |
|----------|-----------------|
| Asset with `?v=` | `public, max-age=31536000, immutable` |
| Asset without | `no-cache` (revalidate every time — a legacy or hand-typed URL can never pin stale code) |
| Any `/app/*` page or API reply | `no-store` (per-session, and it carries the fingerprint) |

## Audit trail

Every attempt is logged (see [security.md](security.md#audit-trail)):

| Event | When |
|-------|------|
| `dashboard.update.start` / `dashboard.update.finish` | Update applied from the panel |
| `maintenance.self_update.start` / `maintenance.self_update.finish` | Update applied over MCP |

The `finish` events carry `ok`, `from_ref`, `to_ref` and `rolled_back`, so a rollback is visible in
the log without reading the tool output.
12 changes: 11 additions & 1 deletion src/beaconmcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
__version__ = "0.1.0"
"""BeaconMCP -- remote MCP server for Proxmox VE and BMC infrastructure."""

try: # pragma: no cover - trivial
from importlib.metadata import version as _version

__version__ = _version("beaconmcp")
except Exception: # noqa: BLE001 - running straight from an uninstalled tree
# Fallback when the package was never pip-installed. Keep in sync with
# [project].version in pyproject.toml. (The old hard-coded "0.1.0" had
# drifted three majors behind it.)
__version__ = "2.0.0"
10 changes: 9 additions & 1 deletion src/beaconmcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2180,6 +2180,8 @@ async def lifespan(_app):
login_limiter=_login_limiter,
trusted_proxies=tuple(config.server.trusted_proxies),
passkey_service=passkey_service,
updates=config.features.updates,
config_path=config.source_path,
)

app = Starlette(
Expand Down Expand Up @@ -2246,7 +2248,8 @@ def _build_dashboard_routes(client_store, token_store, totp_locked,
totp_record_failure, totp_record_success,
*, dyn_reg=None, shared_database=None,
login_limiter=None, trusted_proxies=(),
passkey_service=None):
passkey_service=None, updates=None,
config_path=None):
"""Build dashboard routes if enabled. Returns [] when disabled."""
from . import dashboard
if not dashboard.is_enabled():
Expand Down Expand Up @@ -2320,6 +2323,11 @@ def _float_env(name: str, default: float) -> float:
login_limiter=login_limiter,
trusted_proxies=trusted_proxies,
passkeys=passkey_service,
updates_enabled=updates.enabled if updates is not None else True,
allow_self_update=(
updates.allow_self_update if updates is not None else True
),
config_path=config_path,
)
return build_dashboard_routes(deps)

Expand Down
34 changes: 32 additions & 2 deletions src/beaconmcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,26 @@ class DashboardConfig:
mcp_mode: str = "local" # "local" | "remote"


@dataclass
class UpdatesConfig:
"""Update checking and self-update.

``enabled`` is the network-egress switch: turning it off means the
server never contacts the git remote, which is what an air-gapped or
change-controlled deployment wants. ``allow_self_update`` keeps the
check but removes the ability to apply one from the dashboard or over
MCP -- appropriate when updates go through a deployment pipeline.
"""

enabled: bool = True
allow_self_update: bool = True


@dataclass
class FeaturesConfig:
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
ssh_enabled: bool = True
updates: UpdatesConfig = field(default_factory=UpdatesConfig)


@dataclass
Expand All @@ -183,6 +199,10 @@ class Config:
features: FeaturesConfig
verify_ssl: bool
infrastructure: dict
#: YAML file this config was loaded from, or ``None`` on the legacy
#: env-var path. The self-update flow needs it to re-validate the
#: operator's *actual* config against newly pulled code.
source_path: Path | None = None

# --- Loading ----------------------------------------------------------

Expand Down Expand Up @@ -255,7 +275,7 @@ def _from_yaml(cls, path: Path) -> Config:
if not isinstance(raw, dict):
raise ConfigError(f"{path}: top-level YAML must be a mapping.")
resolved = _resolve_env_refs(raw, path=path)
return cls._build(resolved)
return cls._build(resolved, source_path=path)

@classmethod
def _from_legacy_env(cls) -> Config:
Expand Down Expand Up @@ -328,7 +348,7 @@ def _from_legacy_env(cls) -> Config:
return cls._build(raw)

@classmethod
def _build(cls, raw: dict) -> Config:
def _build(cls, raw: dict, *, source_path: Path | None = None) -> Config:
proxmox_raw = raw.get("proxmox") or {}
nodes_raw = proxmox_raw.get("nodes") or []

Expand Down Expand Up @@ -554,9 +574,14 @@ def _build(cls, raw: dict) -> Config:
public_url=dash_raw.get("public_url"),
mcp_mode=(dash_raw.get("mcp_mode") or "local").strip().lower(),
)
updates_raw = feat_raw.get("updates") or {}
features = FeaturesConfig(
dashboard=dashboard,
ssh_enabled=_bool((feat_raw.get("ssh") or {}).get("enabled", True)),
updates=UpdatesConfig(
enabled=_bool(updates_raw.get("enabled", True)),
allow_self_update=_bool(updates_raw.get("allow_self_update", True)),
),
)

# Cross-capability validation ----------------------------------------
Expand Down Expand Up @@ -597,6 +622,7 @@ def _build(cls, raw: dict) -> Config:
features=features,
verify_ssl=_bool(proxmox_raw.get("verify_ssl", False)),
infrastructure=raw.get("infrastructure") or {},
source_path=source_path,
)

# --- Accessors --------------------------------------------------------
Expand Down Expand Up @@ -748,6 +774,10 @@ def mask(value: str) -> str:
"mcp_mode": self.features.dashboard.mcp_mode,
},
"ssh_enabled": self.features.ssh_enabled,
"updates": {
"enabled": self.features.updates.enabled,
"allow_self_update": self.features.updates.allow_self_update,
},
},
"infrastructure": self.infrastructure,
}
Expand Down
Loading