Skip to main content
Glama
shahinnr

server-admin-mcp

by shahinnr
README.md
# server-admin-mcp

An MCP server that administers a **Debian 13 stage host over SSH**: monitoring, Docker Compose, deployment, PostgreSQL, logs, Nginx/TLS, firewall and scheduled automation — 82 tools and 4 workflow prompts.

> **Stage environments only.** This server gives an assistant broad, real access to a machine. Everything destructive sits behind an explicit confirmation step, and a denylist rejects unrecoverable commands outright, but neither is a substitute for pointing it at a host you can afford to rebuild. See [Security warnings](#security-warnings).

---

## Contents

- [Installation](#installation)
- [Configuration](#configuration)
- [Preparing the server](#preparing-the-server)
- [Connecting a client](#connecting-a-client)
- [Safety model](#safety-model)
- [Tool reference](#tool-reference)
- [Prompts](#prompts)
- [Testing](#testing)
- [Security warnings](#security-warnings)
- [Troubleshooting](#troubleshooting)
- [Design decisions](#design-decisions)

---

## Installation

Requires **Node.js 20 or newer** on the machine running the MCP client (not on the server).

```bash
npm install
npm run build
```

This produces `dist/index.js`, which is the entry point your MCP client launches. The server speaks MCP over **stdio**; it never opens a port of its own.

Available scripts:

| Script | Purpose |
|---|---|
| `npm run build` | Compile TypeScript to `dist/` |
| `npm run typecheck` | `tsc --noEmit` |
| `npm test` | Unit tests for the safety layer (no server needed) |
| `npm run smoke` | Call every read-only tool against the real server and report |
| `npm run lint` | ESLint |
| `npm run format` | Prettier |
| `npm run inspect` | Launch the MCP Inspector against the built server |

---

## Configuration

Every setting is an environment variable prefixed `SRVMCP_`. Copy `.env.example` and fill it in, then pass the values through your client's `env` block.

Optionally, a JSON file with the same keys in camelCase can be pointed at with `SRVMCP_CONFIG_FILE` (default `./config.json` when it exists). **Environment variables always win over the file.**

### SSH connection

| Variable | Required | Default | Description |
|---|---|---|---|
| `SRVMCP_HOST` | **yes** | — | Hostname or IP of the stage server |
| `SRVMCP_PORT` | no | `22` | SSH port |
| `SRVMCP_USER` | **yes** | — | SSH login user (e.g. `deploy`) |
| `SRVMCP_PRIVATE_KEY_PATH` | **yes** | — | Private key used to authenticate. Must exist; `~` is expanded |
| `SRVMCP_PASSPHRASE` | no | — | Passphrase, if the key is encrypted |
| `SRVMCP_KNOWN_HOSTS_PATH` | no | — | `known_hosts` file used to verify the server's key. **Strongly recommended** |
| `SRVMCP_INSECURE_SKIP_HOST_KEY_CHECK` | no | `false` | Disables host-key verification. Throwaway lab hosts only |

### Project layout on the server

| Variable | Required | Default | Description |
|---|---|---|---|
| `SRVMCP_PROJECT_ROOT` | no | `/opt/stage` | Directory holding the compose file. Always writable; writes here need no extra confirmation |
| `SRVMCP_COMPOSE_FILE` | no | `docker-compose.yml` | Relative to `PROJECT_ROOT`, or absolute |
| `SRVMCP_ALLOWED_PATHS` | no | `/opt/stage,/etc/nginx,/var/log` | Comma-separated roots for file operations. `PROJECT_ROOT` is always included |

### PostgreSQL

Reached by running `psql` **on the server**, so `SRVMCP_PG_HOST` is resolved from the server's point of view (`localhost` or `127.0.0.1` for a container publishing 5432).

| Variable | Required | Default | Description |
|---|---|---|---|
| `SRVMCP_PG_HOST` | no | `localhost` | Database host, as seen from the server |
| `SRVMCP_PG_PORT` | no | `5432` | Database port |
| `SRVMCP_PG_USER` | for `pg_*` | — | Read-only role used by every read tool |
| `SRVMCP_PG_PASSWORD` | no | — | Password for that role |
| `SRVMCP_PG_DATABASE` | for `pg_*` | — | Default database |
| `SRVMCP_PG_WRITE_USER` | no | — | Write role. **Leave unset to make every database write impossible** |
| `SRVMCP_PG_WRITE_PASSWORD` | no | — | Password for the write role |

### Limits and behaviour

| Variable | Required | Default | Description |
|---|---|---|---|
| `SRVMCP_MAX_OUTPUT_LINES` | no | `200` | Line cap per command; excess is removed from the middle |
| `SRVMCP_COMMAND_TIMEOUT_MS` | no | `60000` | Default command timeout. The process is killed on the server when it expires |
| `SRVMCP_AUDIT_LOG_PATH` | no | `./audit.log` | JSONL audit trail (local to the client machine) |
| `SRVMCP_READ_ONLY` | no | `false` | When `true`, tools with `readOnlyHint: false` are **not registered at all** |
| `SRVMCP_CONFIG_FILE` | no | `./config.json` | Optional JSON config file |

Configuration is validated at startup with zod. A missing or wrong value produces a message naming the variable and how to fix it, and the process exits before the transport opens — so the client shows the error instead of hanging.

---

## Preparing the server

The server should connect as a dedicated, restricted user — not `root`.

### 1. Create the `deploy` user

```bash
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
```

Add the MCP server's **public** key:

```bash
echo 'ssh-ed25519 AAAA... server-admin-mcp' | sudo tee -a /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
```

### 2. Grant Docker access

```bash
sudo usermod -aG docker deploy
```

Group membership only applies to **new** sessions — restart the MCP server afterwards so it reconnects.

### 3. Give the project directory to `deploy`

```bash
sudo chown -R deploy:deploy /opt/stage
```

### 4. A narrow sudoers file

Only the binaries the tools actually need, with no password prompt (an interactive prompt would hang a non-interactive session):

```bash
sudo visudo -f /etc/sudoers.d/deploy-mcp
```

```
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl, /usr/sbin/nginx, /usr/bin/journalctl, \
    /usr/bin/apt-get, /usr/sbin/ufw, /usr/bin/certbot, /usr/bin/install, \
    /usr/bin/crontab, /usr/sbin/logrotate, /usr/bin/systemd-analyze, \
    /usr/bin/fail2ban-client, /bin/ln, /bin/rm, /bin/cat, /usr/bin/openssl
```

```bash
sudo chmod 440 /etc/sudoers.d/deploy-mcp
sudo visudo -c        # verify before logging out
```

Grant only what you need. Dropping `/usr/bin/apt-get` disables package upgrades; dropping `/usr/sbin/ufw` disables firewall changes. Each affected tool then returns an actionable "this needs a sudoers entry" error rather than failing obscurely.

To read logs without sudo, add the user to `adm` instead:

```bash
sudo usermod -aG adm deploy    # /var/log/nginx, journal
```

### 5. Create a read-only PostgreSQL role

```sql
CREATE ROLE mcp_ro LOGIN PASSWORD 'choose-a-strong-password';
GRANT CONNECT ON DATABASE stage_db TO mcp_ro;
GRANT USAGE ON SCHEMA public TO mcp_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_ro;
```

The `ALTER DEFAULT PRIVILEGES` line matters: without it, tables created by future migrations are invisible to `mcp_ro`. It applies to objects created by the role that runs it, so run it as the role owning your migrations. `pg_fix_schema_permissions` applies this whole set for you.

Optionally, a separate write role for `pg_write`, `pg_maintenance`, `pg_restore_db` and `pg_fix_schema_permissions`:

```sql
CREATE ROLE mcp_rw LOGIN PASSWORD 'another-strong-password';
GRANT CONNECT ON DATABASE stage_db TO mcp_rw;
GRANT USAGE, CREATE ON SCHEMA public TO mcp_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mcp_rw;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO mcp_rw;
```

**Leaving `SRVMCP_PG_WRITE_USER` unset makes every database write structurally impossible**, which is a good default until you need one.

`psql` must be installed on the server: `sudo apt-get install -y postgresql-client`.

### 6. Record the host key

```bash
ssh-keyscan -p 22 stage.example.com >> ~/.ssh/known_hosts
```

Verify the fingerprint out of band before trusting it, then point `SRVMCP_KNOWN_HOSTS_PATH` at that file.

---

## Connecting a client

### Claude Desktop / Claude Code

`claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`):

```json
{
  "mcpServers": {
    "server-admin": {
      "command": "node",
      "args": ["/absolute/path/to/server-admin-mcp/dist/index.js"],
      "env": {
        "SRVMCP_HOST": "stage.example.com",
        "SRVMCP_USER": "deploy",
        "SRVMCP_PRIVATE_KEY_PATH": "/home/you/.ssh/id_ed25519",
        "SRVMCP_KNOWN_HOSTS_PATH": "/home/you/.ssh/known_hosts",
        "SRVMCP_PROJECT_ROOT": "/opt/stage",
        "SRVMCP_ALLOWED_PATHS": "/opt/stage,/etc/nginx,/var/log",
        "SRVMCP_PG_HOST": "localhost",
        "SRVMCP_PG_USER": "mcp_ro",
        "SRVMCP_PG_PASSWORD": "...",
        "SRVMCP_PG_DATABASE": "stage_db",
        "SRVMCP_AUDIT_LOG_PATH": "/var/log/server-admin-mcp/audit.log"
      }
    }
  }
}
```

Use **absolute paths** — the client's working directory is not yours.

A read-only instance, useful for giving an assistant visibility without any ability to change anything:

```json
{
  "mcpServers": {
    "stage-readonly": {
      "command": "node",
      "args": ["/absolute/path/to/server-admin-mcp/dist/index.js"],
      "env": { "SRVMCP_READ_ONLY": "true", "SRVMCP_HOST": "...", "SRVMCP_USER": "deploy" }
    }
  }
}
```

---

## Safety model

Four independent layers, applied in this order:

### 1. Denylist — absolute, cannot be overridden

Checked on the fully assembled command string inside the SSH layer, so **no tool can route around it**, and `confirm: true` does not help. It targets commands that are unrecoverable on a remote box with no console:

| Rule | Blocks |
|---|---|
| `rm-rf-root` / `rm-rf-root-alt-flags` | `rm -rf /` and recursive deletes of system directories, in any flag order |
| `rm-home-wildcard` | Wildcard deletion of a home directory |
| `filesystem-format` | `mkfs`, `fdisk`, `parted`, `sgdisk`, `mkswap` |
| `dd-to-device` | `dd of=/dev/sda` and similar |
| `redirect-to-block-device` | `> /dev/sda`, `> /dev/nvme0n1` |
| `power-state-change` | `shutdown`, `reboot`, `halt`, `poweroff`, `init 0/6`, `systemctl reboot` |
| `chmod-777-system`, `chmod-777-root-shorthand` | `chmod -R 777` on `/` or a system path |
| `chown-recursive-system` | `chown -R` on `/` or a system path |
| `fork-bomb` | `:(){ :\|:& };:` |
| `system-user-delete` | `userdel`/`groupdel` on system accounts |
| `firewall-flush`, `firewall-default-drop` | `iptables -F`, `nft flush ruleset`, `ufw reset`, default-DROP on INPUT |
| `history-wipe` | `history -c`, truncating shell history |
| `remote-code-execution` | `curl … \| bash`, `wget … \| sh`, and the python/perl/ruby/node variants |
| `overwrite-shadow` | Writing over `/etc/shadow`, `/etc/passwd`, `/etc/sudoers` |
| `ssh-lockout` | Deleting or truncating `authorized_keys` |
| `docker-kill-all` | `docker rm $(docker ps -q)` and friends |

The error names the rule that fired, so it is obvious what triggered and why.

Beyond the denylist there is one more hard guard: **`sec_firewall_rule` refuses to deny or delete the SSH port**, because applying that rule would disconnect the server permanently.

### 2. Confirmation — preview, then execute

Destructive tools take `confirm: boolean`. Without `confirm: true` the tool **executes nothing** and instead returns a preview: the exact command, the resulting state change, what is irreversible, and what to check first.

```
PREVIEW — nothing has been executed.

Command that would run:
  $ docker volume prune -f

Effect: Deletes every Docker volume not currently attached to a container.
Irreversible: YES — volume data cannot be recovered. A stopped database
container makes its data volume look unused.

Before approving:
  - Run compose_ps first: every stack service must exist, otherwise its
    volume counts as unused.
  - Take a database backup with pg_backup and copy it off the server first.

To execute, call docker_prune again with the same arguments plus confirm: true.
```

Tools requiring confirmation: `sys_service_control` (stop only), `sys_run_command` (when the command mutates state), `docker_exec` (same), `compose_action` (`down`/`stop`/`rm`), `docker_prune`, `docker_volume_remove`, `git_reset`, `deploy_run`, `migration_run`, `pg_write`, `pg_kill_query`, `pg_maintenance`, `pg_restore_db`, `pg_fix_schema_permissions`, `nginx_write_config`, `nginx_enable_site`, `nginx_disable_site`, `ssl_renew`, `sec_firewall_rule`, `sec_package_upgrade`, `env_set`, all five `auto_*` tools, and `file_write`/`file_patch` **outside `PROJECT_ROOT`**.

For free-form commands (`sys_run_command`, `docker_exec`) confirmation is demanded when the command contains `rm`, `mv`, `truncate`, `kill`/`pkill`, `chmod`/`chown`, `apt`, an overwriting redirect (`>`, but not `2>&1`), a Docker removal, or destructive SQL.

### 3. Path allowlist — checked twice

Every file operation normalises the path (resolving `..`), then verifies it is inside one of `SRVMCP_ALLOWED_PATHS`. A second check runs **on the server after `readlink -f`**, so a symlink planted inside an allowed root cannot be used to reach outside it. A sibling with a shared prefix (`/opt/stagemore` against `/opt/stage`) is correctly excluded.

### 4. Audit log — every call, JSONL

Every invocation is appended to `SRVMCP_AUDIT_LOG_PATH`, successful or not:

```json
{"ts":"2026-07-25T10:00:00.000Z","tool":"compose_action","args":{"action":"restart","services":["api"],"confirm":true},"status":"ok","command":"docker compose -f /opt/stage/docker-compose.yml restart api","exitCode":0,"durationMs":1234,"confirmed":true}
```

Writes are queued so concurrent calls cannot interleave, and a failure to write warns on stderr without aborting the tool. Credentials are masked before anything is written.

### Read-only mode

With `SRVMCP_READ_ONLY=true`, tools whose `readOnlyHint` is false are **never registered** — 49 tools are exposed instead of 82, and a write tool is not merely refused but absent from `tools/list` entirely. There is no runtime flag an assistant could flip.

### Secret masking

Everything leaving the process — tool text, structured content and audit records — passes through a masker covering `password`/`secret`/`token`/`api_key`/`auth`/`credential`-style keys, `Bearer`/`Basic` headers, credentials embedded in URLs, JWTs, PEM private-key blocks and `PGPASSWORD=`. `docker_inspect` masks environment values by key name, and `env_read` masks every value unless a single `revealKey` is named explicitly.

Database credentials are never placed on a command line: a `~/.srvmcp.pgpass` file (mode 0600) is written once and referenced through `PGPASSFILE`, keeping the password out of `ps` output entirely.

---

## Tool reference

82 tools. `RO` marks read-only (available in read-only mode); `!` marks tools requiring `confirm`.

### System — `sys_*` (10)

| Tool | | Description |
|---|---|---|
| `sys_metrics` | RO | CPU, load, RAM, swap, every filesystem, uptime — one structured snapshot |
| `sys_top_processes` | RO | Heaviest processes by CPU or memory |
| `sys_disk_usage` | RO | Directory sizes under a path, largest first |
| `sys_ports` | RO | Listening TCP/UDP sockets with the owning process |
| `sys_services` | RO | systemd units and their state; `onlyFailed` to scan for breakage |
| `sys_service_control` | ! | start / stop / restart / reload / status a unit (`stop` needs confirm) |
| `sys_uptime_reboots` | RO | Uptime, recent reboots, OOM kills from the kernel log |
| `sys_run_command` | ! | Arbitrary shell command, subject to the denylist |
| `sys_run_background` | | Start a long command detached; returns PID and log path |
| `sys_check_background` | RO | Status and log tail of a background job |

### Docker — `docker_*`, `compose_*` (13)

| Tool | | Description |
|---|---|---|
| `docker_ps` | RO | Containers with state, health, ports, uptime |
| `docker_stats` | RO | Per-container CPU / memory / network / block I/O sample |
| `docker_logs` | RO | Container logs with `tail`, `since` and `grep` filters |
| `docker_inspect` | RO | Restart count, exit code, OOM flag, health, mounts, networks, masked env |
| `docker_exec` | ! | Run a command inside a container |
| `compose_ps` | RO | Compose service states |
| `compose_action` | ! | `up`/`down`/`restart`/`build`/`pull`/`stop`/`rm` |
| `compose_config` | RO | Fully resolved compose configuration; validates the file |
| `docker_images` | RO | Images with size and age |
| `docker_prune` | ! | Reclaim space: images / volumes / builder / containers / system |
| `docker_networks` | RO | Networks and the containers attached to each |
| `docker_volumes` | RO | Volumes with size and whether anything uses them |
| `docker_volume_remove` | ! | Delete one named volume |

### Deployment and git — `git_*`, `deploy_*`, `migration_*` (9)

| Tool | | Description |
|---|---|---|
| `git_status` | RO | Branch, HEAD, ahead/behind (fetches first), working-tree changes |
| `git_pull` | | Fast-forward-only pull |
| `git_checkout` | | Switch to a branch, tag or commit |
| `git_log` | RO | Recent commits |
| `git_reset` | ! | Rollback to a ref; returns the previous SHA so it can be undone |
| `deploy_health_check` | RO | Request URLs from the server; status code and response time |
| `deploy_run` | ! | pull → build → up -d → settle → container check → health check, stopping at the first failure |
| `migration_run` | ! | Apply EF Core / Django / Alembic / custom migrations in a container |
| `migration_status` | RO | Applied vs shipped migrations, reporting pending and drift |

### PostgreSQL — `pg_*` (13)

| Tool | | Description |
|---|---|---|
| `pg_query` | RO | Read-only SQL as the read-only role; auto-`LIMIT`; writes rejected |
| `pg_write` | ! | INSERT / UPDATE / DELETE / DDL as the write role |
| `pg_databases` | RO | Databases with owner, encoding and size |
| `pg_tables` | RO | Tables with estimated rows, table and index size |
| `pg_table_schema` | RO | Columns, indexes and foreign keys of one table |
| `pg_activity` | RO | Active backends, query age, lock waits |
| `pg_kill_query` | ! | Cancel (default) or terminate a backend |
| `pg_slow_queries` | RO | Slowest statements via `pg_stat_statements`, with setup guidance if absent |
| `pg_maintenance` | ! | VACUUM / VACUUM FULL / ANALYZE / REINDEX |
| `pg_backup` | | `pg_dump -Fc` to a directory; returns path and size |
| `pg_restore_db` | ! | Restore a custom-format dump |
| `pg_permissions` | RO | Roles, schema ACL and table grants |
| `pg_fix_schema_permissions` | ! | Apply the standard grant set incl. `ALTER DEFAULT PRIVILEGES` (the PG15+ trap) |

### Logs — `log_*` (6)

| Tool | | Description |
|---|---|---|
| `log_journal` | RO | journalctl with unit/time/priority/grep filters, repeated messages grouped |
| `log_tail` | RO | Tail a log file with optional (invertible) grep |
| `log_nginx_errors` | RO | Nginx errors grouped by message, upstream failures isolated |
| `log_nginx_access_summary` | RO | Requests by status, busiest paths, noisiest IPs, slowest requests, error rate |
| `log_search` | RO | grep across several files with context |
| `log_elasticsearch_query` | RO | POST a search body to Elasticsearch from the server |

### Nginx and TLS — `nginx_*`, `ssl_*` (9)

| Tool | | Description |
|---|---|---|
| `nginx_list_sites` | RO | vhosts with enabled state, server names, listens, upstreams |
| `nginx_read_config` | RO | Read a config file from `/etc/nginx` |
| `nginx_write_config` | ! | Backup → write → `nginx -t` → **automatic rollback on failure** |
| `nginx_test` | RO | `nginx -t` |
| `nginx_reload` | | Test, then reload only if the test passes |
| `nginx_enable_site` | ! | Symlink into `sites-enabled`, validated, auto-reverted on failure |
| `nginx_disable_site` | ! | Remove the symlink; the vhost file is untouched |
| `ssl_cert_info` | RO | Certificate issuer, domains, expiry, days remaining |
| `ssl_renew` | ! | `certbot renew`, dry run by default |

### Security — `sec_*` (9)

| Tool | | Description |
|---|---|---|
| `sec_firewall_status` | RO | ufw / nftables / iptables rules, plus an SSH-reachability sanity check |
| `sec_firewall_rule` | ! | allow / deny / delete a ufw rule (refuses to touch the SSH port) |
| `sec_fail2ban_status` | RO | Jails, failure counts, banned addresses |
| `sec_ssh_attempts` | RO | Failed logins grouped by source, **successful logins listed separately** |
| `sec_users` | RO | Accounts, shells, sudo membership, last login, locked state |
| `sec_ssh_keys` | RO | authorized_keys per account — fingerprints and comments only |
| `sec_package_updates` | RO | Upgradable packages, security ones flagged, reboot-required state |
| `sec_package_upgrade` | ! | Non-interactive apt upgrade, keeping existing config files |
| `sec_cron_jobs` | RO | User crontabs, `/etc/cron.*`, and systemd timers |

### Files — `file_*`, `env_*` (8)

| Tool | | Description |
|---|---|---|
| `file_read` | RO | Read a file over SFTP (1 MB cap), optionally a line range |
| `file_write` | ! | Atomic write with automatic timestamped backup |
| `file_patch` | ! | Replace one **unique** string; ambiguous matches are refused |
| `file_list` | RO | Directory listing with size, owner, permissions, mtime |
| `file_search` | RO | Find files by name glob and/or content regex |
| `file_stat` | RO | Metadata for one path, including symlink target |
| `env_read` | RO | dotenv keys with **masked values** unless one `revealKey` is named |
| `env_set` | ! | Add or update one key, preserving comments and ordering |

### Automation — `auto_*` (5)

| Tool | | Description |
|---|---|---|
| `auto_create_cron` | ! | Add a cron job (schedule validated first), tagged `# SRVMCP` |
| `auto_remove_cron` | ! | Remove matching cron lines and their marker comment |
| `auto_create_systemd_unit` | ! | Write a unit, `daemon-reload`, verify, optionally enable and start |
| `auto_setup_backup` | ! | pg_dump script + retention + cron, **test-run once before scheduling** |
| `auto_setup_logrotate` | ! | logrotate config, validated with a dry run and removed if invalid |

---

## Prompts

| Prompt | Arguments | What it does |
|---|---|---|
| `diagnose_service` | `service` | State → history → logs → resources → connectivity → external health, ending in a stated root cause with evidence |
| `daily_health_report` | — | Full read-only sweep, reported by urgency rather than by category |
| `pre_deploy_check` | `services?` | Disk, git cleanliness, incoming commits, current health, compose validity, database, backups → **GO / NO-GO** plus the rollback SHA |
| `investigate_5xx` | `since?` | Correlates Nginx 5xx with backend logs, resources and database over one window |

---

## Testing

### Unit tests — no server required

```bash
npm test
```

134 assertions covering the denylist (each dangerous command individually, plus a set of safe ones that must **not** be blocked), the path allowlist including traversal and shared-prefix cases, middle-out truncation, control-character sanitising, secret masking, shell quoting, read-only SQL classification (including `SELECT 1; DROP TABLE users`), and CSV parsing.

### Schema inspection

```bash
npm run inspect
```

Confirms all 82 tools list with valid input and output schemas and complete annotations.

### Smoke test — read-only, against the real server

```bash
npm run smoke              # every read-only tool
npm run smoke -- sys_ pg_  # only these prefixes
```

Calls each read-only tool in turn and prints a pass/fail table. It never invokes a tool whose `readOnlyHint` is false, so it is safe against a live stage box.

---

## Security warnings

**This server grants an assistant broad, real access to a machine.** Treat it accordingly.

- **Stage only.** Do not point it at production. Nothing here distinguishes the two for you.
- **Never connect as `root`.** Use a dedicated user with the narrow sudoers file above. The safety layers are defence in depth, not a replacement for least privilege.
- **Prefer read-only.** `SRVMCP_READ_ONLY=true` removes 33 write tools from the interface entirely. Run a second, writable instance only when you need it.
- **Leave `SRVMCP_PG_WRITE_USER` unset** until a database write is genuinely required — that makes `pg_write`, `pg_restore_db` and `pg_maintenance` structurally unavailable.
- **Set `SRVMCP_KNOWN_HOSTS_PATH`.** Without it the first connection is trusted blindly, which makes a man-in-the-middle attack possible on the first connect.
- **Review the audit log.** It records every call with arguments and exit codes. It is the only record of what was actually done:
  ```bash
  tail -f audit.log | jq 'select(.confirmed == true)'          # everything executed with confirmation
  jq 'select(.status == "error")' audit.log                     # everything that failed
  jq -r '[.ts, .tool, .command] | @tsv' audit.log               # a readable timeline
  ```
- **Keep `SRVMCP_ALLOWED_PATHS` tight.** Every added root widens what can be read and written. Adding `/` defeats the mechanism.
- **The private key is the whole boundary.** Anyone who can read it, and the environment holding the passphrase, has the same access this server does.
- **Backups made by `pg_backup` and `auto_setup_backup` stay on the same host as the database.** They protect against a bad migration, not against losing the server. Arrange an off-box copy separately.

---

## Troubleshooting

### `SSH authentication to deploy@host:22 failed (permission denied)`

The key is wrong or not authorised. Reproduce outside the MCP server:

```bash
ssh -i ~/.ssh/id_ed25519 -p 22 deploy@stage.example.com
```

Check the public half is in `/home/deploy/.ssh/authorized_keys`, and that modes are `700` on `~/.ssh` and `600` on `authorized_keys` — sshd silently refuses looser permissions.

### `Host key verification failed`

Either the host is not in `known_hosts` yet, or its key changed. Verify the fingerprint out of band, then:

```bash
ssh-keyscan -p 22 stage.example.com >> ~/.ssh/known_hosts
```

A **changed** key on a host you have connected to before deserves investigation before you clear the old entry.

### `Cannot talk to the Docker daemon: permission denied on /var/run/docker.sock`

```bash
sudo usermod -aG docker deploy
```

Then restart the MCP server — group membership only applies to new sessions.

### `The command needs sudo but the account cannot use it without a password`

The binary is missing from `/etc/sudoers.d/deploy-mcp`. Add it, then `sudo visudo -c` to verify. A prompting sudo hangs a non-interactive session, which is why `sudo -n` is used throughout.

### `The command timed out after 60s`

Either raise `timeoutMs` for that call, or start the work in the background:

```
sys_run_background(command: "...")   ->  sys_check_background(pid: 1234)
```

Builds, migrations, large upgrades and `pg_dump` on a big database are the usual candidates. `deploy_run` already allows 30 minutes for the build step.

### `Cannot reach PostgreSQL at localhost:5432`

`SRVMCP_PG_HOST` is resolved **from the server's point of view**. If the database runs in Docker, check `docker_ps` for the published port — usually `127.0.0.1:5432`. If it is not published at all, run queries inside the container with `docker_exec` instead.

### `Refused: path "/etc/shadow" is outside the configured allowlist`

Working as intended. Add the root to `SRVMCP_ALLOWED_PATHS` only if you genuinely want it reachable.

### `permission denied for schema public` after upgrading to PostgreSQL 15

PostgreSQL 15 removed the implicit `CREATE` grant on `public` from `PUBLIC`. Fix it in one call:

```
pg_fix_schema_permissions(user: "app_user", schema: "public", confirm: true)
```

### A tool is missing from `tools/list`

Check whether `SRVMCP_READ_ONLY=true` — write tools are not registered in that mode. The server logs `N tools registered, M withheld (read-only mode)` to stderr at startup.

### Nginx logs are unreadable

They are root-owned by default:

```bash
sudo usermod -aG adm deploy
```

### The audit log is not being written

The server warns once on stderr and keeps working. Check that the directory exists and is writable by whoever runs the MCP client.

---

## Design decisions

Choices made where the specification left room, recorded here as required.

**Project at the repository root, not in a `server-admin-mcp/` sub-directory.** The working directory was already the project root; nesting would have added a level for no benefit. The package is still named `server-admin-mcp`.

**Two extra source files beyond the specified layout.** `src/utils/shell.ts` holds shell quoting, and `src/safety/paths.ts` holds the path allowlist — both are separate concerns that would otherwise have been duplicated across nine tool modules. `src/tools/registry.ts`, `src/tools/types.ts` and `src/tools/context.ts` hold the shared registration, envelope and dependency plumbing that keeps each tool module free of boilerplate. `src/ssh/knownHosts.ts` implements host-key verification, which ssh2 leaves to the caller.

**Manual POSIX quoting instead of `shell-quote`.** Single-quoting is five lines; for a server with this much reach, one fewer dependency to audit was worth more than the package.

**`PGPASSFILE` instead of `PGPASSWORD`.** The specification asked for the password to be passed by environment rather than visibly in the command. Because the environment is exported inside the remote `sh -c` string, `PGPASSWORD` would still appear in `ps` output. A `~/.srvmcp.pgpass` file at mode 0600, referenced through `PGPASSFILE`, keeps it out of process listings entirely.

**Uniform output envelope.** Every tool's `outputSchema` carries `status` and `message` alongside its own fields, with tool-specific fields optional. That is what lets a `confirm` preview validate against the same schema as a successful run, so previews are ordinary results rather than errors.

**Statement batching rejected in `pg_query`.** Checking only the first keyword would let `SELECT 1; DROP TABLE users` through. String literals and comments are stripped before keyword matching so a value like `'delete from users'` is not mistaken for a write.

**Remote `timeout(1)` plus a local timer.** Killing the SSH channel alone leaves the remote process running. The command is wrapped in `timeout -k 5s`, so it dies on the server; the local timer is only a backstop for a wedged channel.

**Atomic writes and double path checking.** Files are written to a temporary sibling and renamed, so an interrupted transfer cannot leave a half-written config. Paths are checked textually and again on the server after `readlink -f`, so a symlink inside an allowed root cannot escape it.

**`ssl_renew` defaults to `dryRun: true`.** Let's Encrypt rate-limits real issuance; a dry run costs nothing and catches a broken challenge setup.

**`sec_firewall_rule` will not touch the SSH port.** Not a confirmation — a refusal. Applying such a rule would disconnect the server with no way back in, so it must be done from a console session.

**`auto_setup_backup` test-runs the script before scheduling it.** A backup that only fails at 3 a.m. is worse than no backup, so nothing is added to cron until one real dump has succeeded.

**Extra tools beyond the specification.** `file_stat` (metadata for a single path, useful when diagnosing permissions) and `migration_status` support for Alembic alongside EF Core and Django.

**`docker_exec` requires confirmation for mutating commands,** matching the rule the specification set for `sys_run_command`; it is the same class of escape hatch.

Maintenance

ActivitySlowing
ResponsivenessNo issues