Scope
01 / 06Five tool groups, one safety model
Organized the way a NAS owner thinks about daily operations. Every mutating operation plans first; read operations run directly.
| Group | Covers |
|---|---|
| system | Connectivity & system. SSH check, OS/hardware report (CPU, memory, thermals, disks), and storage-pool health across mdadm RAID, LVM, btrfs, and block devices. |
| docker | Containers, images, networks, compose. Full lifecycle: list, inspect, logs, start/stop/restart, remove; image pull/remove; network create/remove/connect/disconnect; compose up/down/restart. |
| files | Shares & data. Browse, measure, search; SFTP upload/download (chroot-aware); rsync-style sync with a built-in cp fallback; ffmpeg thumbnails rendered on the NAS. |
| backup | Archives & schedules. tar.gz creation, listing, verification (checksum + entry count), restore, and managed recurring cron jobs that preserve your existing crontab entries. |
| network | Host networking. Interface inventory with IPv4/IPv6 scopes, both routing tables, and listening TCP/UDP sockets with owning processes. |
Safety
02 / 06Dry-run first, then confirm
Every mutating tool is a two-phase machine. Nothing destructive happens on the first call — ever.
Call a mutating tool with its arguments — files.remove with path: "media/old". Nothing runs yet.
Returns a dry-run plan: the exact command it would run — rm -rf '/volume1/media/old' — plus a summary, warnings, and a confirmation token.
Do nothing. The plan expires in 10 minutes; nothing ever touched the NAS.
Re-call the same tool with the same arguments, plus confirmToken.
Validates the token (single-use, action-bound, unexpired), cross-checks the arguments against the plan, then executes the exact commands captured at plan time — verbatim, on the NAS.
Injection-proof by construction. Every user value — paths, container names, image tags — passes through POSIX single-quote escaping before it touches a shell. Backup job names are restricted to [A-Za-z0-9._-] and cron expressions validated as exactly five fields, because both end up inside shell scripts and crontab lines.
Root paths are refused outright. files.remove will not plan a deletion of /, a bare share root (/volume1), top-level system directories, or UGOS-managed directories like @docker — it rejects before planning.
Pinned by fingerprint. Optionally pin the SSH host key (hostKeySha256); comparisons are padding-insensitive so the canonical ssh-keygen -lf output works as-is.
Structure
03 / 06A small, layered codebase
One module per tool group. A narrow executor interface so the test suite runs against a fake — no NAS required.
src/
├── server.ts entrypoint: stdio + optional stateless HTTP (/mcp, /health)
├── config.ts zod-validated config; UGNAS_* env overrides win over the file
├── mcp/
│ ├── createServer.ts McpServer wrapper
│ └── toolset.ts all 39 tool registrations
├── safety/plan.ts ConfirmationTokenService — the dry-run/confirm core
├── ssh/
│ ├── sshExecutor.ts CommandExecutor interface + ssh2-backed SshExecutor
│ └── shellQuote.ts shq() — every user value passes through it
└── services/ one class per tool group, each takes ServiceContext
├── systemService status / info / pools
├── dockerService containers / images / networks / compose
├── fileService list / usage / find / thumbnail / mkdir / copy / move
│ / remove / upload / download / sync
├── backupService create / list / verify / restore / schedule
├── networkService interfaces / routes / ports
└── util.ts planOrExecute, path resolution, output parsers
One round-trip for many reads. Read-heavy tools batch commands behind ###NAME### markers and parse the sections from a single SSH call — system.info gathers OS, CPU, load, memory, uptime, thermals, disks, and the Docker version in one shot.
Chroot-aware SFTP. UGOS chroots SFTP to the share root: exec path /volume1/docker/x becomes SFTP path /docker/x. The sftpRoot config owns the mapping; thumbnails travel via base64-over-exec because /tmp sits outside the chroot.
BusyBox-tolerant. GNU-only flags (ls --time-style, find -printf) are probed once and cached; on BusyBox the file tools fall back to ls -1p and find -exec stat and keep working.
Index
04 / 06The complete ledger — 39 tools
read runs directly gated plans first dstr gated, destructive, warned
| system.status | SSH connectivity, hostname, kernel, uptime, Docker availability. | read |
| system.info | OS release, CPU cores, load, memory, thermals, disks, Docker version. | read |
| system.pools | mdadm arrays + degradation, LVM, btrfs, block devices, filesystems. | read |
| docker.containers.list | Containers with id, name, image, state, status. | read |
| docker.containers.inspect | Full docker inspect JSON for one container. | read |
| docker.containers.logs | Recent log lines (tail, default 200). | read |
| docker.containers.start | Start a stopped container. | gated |
| docker.containers.stop | Stop a running container. | gated |
| docker.containers.restart | Restart a container. | gated |
| docker.containers.remove | Delete a container (optional force / anonymous volumes). | dstr |
| docker.images.list | Locally stored images. | read |
| docker.images.pull | Pull an image from its registry. | gated |
| docker.images.remove | Remove a local image. | gated |
| docker.networks.list | Docker networks. | read |
| docker.networks.inspect | Network details: subnets, attached containers. | read |
| docker.networks.create | Create a network (driver, subnet optional). | gated |
| docker.networks.remove | Remove a network (fails if containers attached). | gated |
| docker.networks.connect | Attach a container to a network. | gated |
| docker.networks.disconnect | Detach a container from a network. | gated |
| docker.compose | up -d / down / restart for a stack directory. | gated |
| files.list | Directory listing (name, type, size, owner, modified). | read |
| files.usage | Disk usage (du), optionally per subdirectory. | read |
| files.find | Search by name glob and/or extension; path, size, mtime. | read |
| files.thumbnail | ffmpeg preview of an image/video frame → local file (write gated). | gated |
| files.mkdir | Create a directory (parents included). | gated |
| files.copy | Copy within the NAS (or NAS-to-NAS). | gated |
| files.move | Move or rename a path. | gated |
| files.remove | Delete a file or directory (recursive by default; root paths refused). | dstr |
| files.upload | SFTP upload, local machine → NAS. | gated |
| files.download | SFTP download, NAS → local machine. | gated |
| files.sync | rsync sync with mirror/dry-run; cp -a fallback when rsync is missing. | gated |
| backup.create | Archive a directory into a .tar.gz on the NAS. | gated |
| backup.list | Archives (*.tar.gz, *.tgz, *.tar, *.zip) in a directory. | read |
| backup.verify | Archive exists, checksum, entry count, sample entries. | read |
| backup.restore | Extract an archive into a destination (overwrites). | dstr |
| backup.schedule | Managed cron job for recurring backups. | gated |
| network.interfaces | Interfaces with IPv4/IPv6 addresses and scopes. | read |
| network.routes | IPv4 and IPv6 routing tables. | read |
| network.ports | Listening TCP/UDP sockets and owning processes. | read |
Setup
05 / 06Configuration & wiring
The server reads a JSON config from ~/.ugnas-mcp/config.json
(override with UGNAS_MCP_CONFIG). Environment variables win over
the file — useful for CI and secret injection: UGNAS_HOST,
UGNAS_PORT, UGNAS_USER, UGNAS_KEY_PATH or
UGNAS_AUTH_TYPE=password + UGNAS_PASSWORD,
UGNAS_DEFAULT_SHARE, UGNAS_HOST_KEY_SHA256.
# ~/.ugnas-mcp/config.json
{
"name": "home-nas",
"host": "192.168.168.110",
"port": 22,
"username": "admin",
"auth": { "type": "key", "privateKeyPath": "~/.ssh/id_ed25519" },
"defaultShare": "/volume1",
"commandTimeoutMs": 60000
}
Client wiring (stdio)
# official package — no clone, no build npx ugnas-mcp # or: npm i -g ugnas-mcp && ugnas-mcp # client config { "mcpServers": { "ugnas": { "command": "npx", "args": ["-y", "ugnas-mcp"], "env": { "UGNAS_MCP_CONFIG": "/absolute/path/to/config.json" } } } }
Optional HTTP transport
stdio by default. Set UGNAS_HTTP_PORT to also expose a stateless
streamable HTTP transport at /mcp (1 MiB body cap) plus
/health. Loopback only, unless UGNAS_HTTP_ALLOW_REMOTE=1 —
the endpoint carries no authentication, so put a proxy with auth in front if
you need it off-box.
UGNAS_HTTP_HOST=127.0.0.1 UGNAS_HTTP_PORT=4110 ugnas-mcp POST 127.0.0.1:4110/mcp # JSON-RPC, stateless GET 127.0.0.1:4110/health # liveness + target host
On the NAS
Required. SSH enabled (UGOS: Control Panel → Terminal & SNMP); the SSH user in the docker group; crontab access for scheduled backups; standard tools (tar, df, du, ip, ss — all present on UGOS).
Optional. rsync (only files.sync; the cp -a fallback covers its absence) and ffmpeg (only files.thumbnail).
Development
06 / 06Developing the server
Everything needed to work on the server — commands, tests, architecture, and the release pipeline — lives in DEVELOPMENT.md. CI (GitHub Actions) runs the full chain — typecheck, tests, build, both smoke tests — on every push and pull request.