Skip to main content
Glama
README.md
# unas-mcp

MCP server for managing a Ubiquiti UNAS Pro 8 NAS (6×26 TB RAID-6, ~104 TB
usable) — health monitoring, storage/disk inventory, NFS export visibility,
read-only file inspection, and gated lifecycle operations. 20 tools, deployed
as a container on VM 102 (`http://10.0.40.25:8490/mcp`).

## What it is

The UNAS Pro 8 exposes two independent surfaces, and this server is a thin,
safety-gated layer over both:

- **SSH** (`root@<host>`, key-only, `src/unas_mcp/ssh.py`) — `/proc/mdstat`,
  `smartctl`, `df`, `rpcinfo`, `exportfs`, `uptime`/`last`, `uname`, and a
  handful of read-only filesystem commands (`ls`, `du`). This is the same
  transport `docs/reference/unas-pro-8-capabilities.md` (in the `homelab`
  repo) documents as voiding Ubiquiti's warranty — used here only where the
  UI and the Drive API cannot answer the question.
- **UniFi Drive API** (`src/unas_mcp/api.py`, unofficial/undocumented —
  findings in `docs/drive-api-findings.md`) — physical bay mapping
  (`slotId`), vendor `healthScore`/`riskReasons`, and per-RAID-group
  `currentProtection` vs `expectedProtection`. SSH's `/proc/mdstat` and
  `smartctl` are enough for pass/fail health, but they don't carry which
  physical bay to pull at the rack or the vendor's own risk scoring — that's
  the entire reason the Drive API transport exists in this server.

Every parser in this project is "tolerant but honest": a field genuinely
absent from a live reply is surfaced as `None` (or raises, for the small set
of fields health decisions are gated on), and is **never** silently defaulted
to a value that reads as healthy (`0`, `"ok"`, `False`, or a label count
standing in for a verdict actually read). See `api.py`, `parsers/smart.py`,
and `tools/storage.py`'s module docstrings for the specific traps this
pattern was built to close.

## Tool inventory (20 tools, 5 groups)

| Group | Tool | Tier | What it does |
|---|---|---|---|
| Health (SSH) | `unas_health_summary` | read | Rolled-up `healthy` \| `unhealthy` \| `unknown` verdict across array, SMART, capacity, and NFS — `unknown` on any transport/parse failure or incomplete reply, never silently `healthy` |
| | `unas_array_status` | read | Parsed `/proc/mdstat` — per-array `[n/m]` state, healthy flag, rebuild/resync/reshape progress |
| | `unas_smart_status` | read | One SMART record per drive bay (sda-sdf): verdict + the three failure-predicting counters |
| | `unas_smart_details` | read | Full SMART health + identity record for one drive bay |
| | `unas_capacity` | read | Media volume capacity in bytes (total/used/avail) + used_pct, from `df -B1` |
| | `unas_nfs_state` | read | Whether nfsd/mountd are registered with the local RPC portmapper |
| | `unas_uptime_boot` | read | `uptime` + last 5 `last -x` boot/shutdown records |
| | `unas_system_info` | read | `uname -a` + `/etc/os-release` |
| Lifecycle (SSH) | `unas_smart_test_start` | direct | Start a SMART self-test (short/long) on a drive bay |
| | `unas_smart_test_status` | direct | Read a drive bay's SMART self-test log |
| | `unas_firmware_status` | read | Firmware/OS release identity |
| | `unas_service_status` | read | Running systemd services |
| | `unas_reboot` | **confirm** | Reboot the appliance — ~9.5 min of NFS downtime for every client; requires `confirm="yes"` |
| Storage (Drive API) | `unas_pool_status` | read | Every pool's status/type/capacity/usage + per-RAID-group protection state |
| | `unas_disk_inventory` | read | Every physical disk + cache slot: `slotId`, model, serial, temp, `healthScore`, `riskReasons`, error counters |
| | `unas_fan_status` | read | Raw fan-control status |
| NFS (SSH) | `unas_nfs_exports` | read | Live `exportfs -v` table — export paths, client allow-lists, squash options |
| | `unas_fix_permissions` | **confirm** | Recursive `chown 977:988` under a share's `.data` root (the UNAS's hardcoded NFS `all_squash` uid/gid) |
| Files (SSH) | `unas_list_dir` | read | `ls -lah` on a path under a share |
| | `unas_disk_usage_by_dir` | read | `du -h --max-depth=N` on a path under a share (bounded depth, 180s timeout) |

**Tiers:** `read` = no gate. `direct` = additive/trivially reversible, no
gate. `confirm` = requires `confirm="yes"` (`safety.requires_confirmation`) —
anything else raises `PermissionError` and issues no remote command.

## Deliberate exclusions

Two categories of tool are **absent by design**, not merely unimplemented —
their absence is the actual protection, not a confirmation prompt sitting in
front of them:

- **No write file operations.** Not move, delete, copy, upload, or mkdir.
  The same bytes are already reachable through nine NFS client mounts with
  ordinary tools (`mv`, `rm`, `cp`, `rsync`, ...) — a second write path to
  the same ~75 TB of data only doubles the ways to corrupt it. See
  `tools/files.py`'s module docstring. (`unas_fix_permissions` is the one
  narrow exception: a `chown`, not a data-touching write, and the only
  survivor of an investigated-and-cut NFS-allow-list write path — see
  `tools/nfs.py`'s module docstring for why that one was rejected.)
- **No pool or disk lifecycle tools at all** — no create/destroy/format/
  reshape/expand for pools, RAID groups, or disks. `safety.py` calls this
  the `absent` tier: "a confirmation prompt is a speed bump; absence is a
  wall." `is_forbidden_tool_name()` in `safety.py` is defence in depth on
  top of that absence (it blocks an obviously-named destructive tool from
  being registered by accident), but the real guarantee is the exact-match
  allowlist test (`tests/test_health_tools.py::
  test_registers_exactly_the_twenty_expected_tool_names`) — any new tool,
  named anything, fails it until a human deliberately updates the expected
  set.

## Environment contract

| Variable | Purpose |
|----------|---------|
| `UNAS_HOST` | Hostname/IP of the UNAS Pro 8 |
| `UNAS_SSH_KEY` | Path to the SSH private key used to reach the UNAS |
| `UNAS_API_KEY` | UniFi Drive API key (`X-API-Key` header) for the storage tools — see `docs/drive-api-findings.md` |
| `MCP_PORT` | Port the MCP server listens on (default `8490`) |

## Development

```bash
python3.12 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest -v
```

## Docker

```bash
docker build -t unas-mcp .
```

The image includes an `openssh-client` for the SSH transport.

## Deployed

Live on **VM 102** (`10.0.40.25:8490`), registered for Claude Code:

```bash
claude mcp add unas --transport http http://10.0.40.25:8490/mcp --scope user
```