mcp-vps-toolkit
README.md
# mcp-vps-toolkit
**A policy-gated MCP server that lets AI assistants inspect and operate your Linux VPS safely: read-only by default, no shell tool, every action allowlisted in YAML and written to a tamper-evident audit log.**
[](https://github.com/mohammadhadijavidi/mcp-vps-toolkit/actions/workflows/ci.yml)
[](LICENSE)
[](pyproject.toml)
[](https://modelcontextprotocol.io)

<sub>The screenshot shows real output from the CLI against the policy on the left. No AI model was involved: `mcp-vps-toolkit call` drives the same MCP tools an assistant would get.</sub>
---
## Why
Asking Claude, Cursor or another AI agent "why is my API returning 502?" works much better when it can look at the server itself. The usual way to give it that access is a generic shell or SSH MCP server, and that hands a language model a root prompt. Prompt injection hidden in a log line, a README or a web page can then turn into `rm -rf` or `curl | sh`.
`mcp-vps-toolkit` takes the opposite approach. It exposes a **small set of typed DevOps tools**, such as service status, journald logs, log tail, disk, CPU, memory, processes, restart and git deploy. It **does not offer a shell tool at all**. Every call goes through a YAML policy that you write, and anything the policy does not list is denied.
How it differs from what already exists:
| | generic SSH / shell MCP servers | read-only Linux MCP servers | **mcp-vps-toolkit** |
|---|---|---|---|
| Arbitrary commands | yes | some offer an opt-in mode | **never** |
| Restart services / deploy | via shell | no | **yes, allowlisted, dry-run first** |
| Single policy file (deny by default, unknown keys rejected) | no | env vars | **yes** |
| Mutating tools invisible in read-only mode | n/a | n/a | **yes** |
| Audit log of every call (allowed, denied, dry-run) | rarely | no | **yes, SHA-256 hash-chained** |
| Secret redaction in logs and process lists | no | no | **yes** |
| Rate limit on mutations | no | n/a | **yes** |
## Features
- **Read-only by default.** In `mode: read-only`, `restart_service` and `deploy` are not even registered with the MCP server, so the model never sees them.
- **No shell, ever.** Commands are fixed argv lists run with `shell=False`. Only four programs are allowed (`systemctl`, `journalctl`, `git`, and `sudo -n systemctl`), resolved against a fixed `PATH` with a minimal environment.
- **YAML allowlists** for systemd units (read vs restart), log file globs, disk paths and git deploy targets. Patterns that match every unit, such as `*`, are rejected.
- **Strict input validation** happens before the policy check: unit names, `since` expressions, substring filters (never regex) and absolute paths. Symlinks must resolve inside the allowlist too.
- **Dry-run first.** Mutating tools default to `dry_run=true` and return the exact argv they would run. `dry_run: true` in the policy forces plan-only mode whatever the model asks.
- **Safe git deploy.** Deploys only fast-forward: no reset, no force and no merge of diverged history. They refuse a dirty tree or the wrong branch, disable git hooks, block the `ext::` transport, and restart only allowlisted units, and only when HEAD changed.
- **Tamper-evident audit log.** Each call writes one JSONL line with its args (redacted), decision, argv, result and duration. Every line carries the SHA-256 of the previous line, so `mcp-vps-toolkit audit verify` detects edited or deleted lines.
- **Secret redaction.** API keys (OpenAI, Anthropic, AWS, GitHub, Slack, Telegram), JWTs, bearer tokens, `password=` style pairs, URL credentials and PEM private keys are masked in all output.
- **Hard limits.** Output is capped by line, byte and character count. Commands have timeouts. A per-hour budget applies to executed mutations and survives restarts because it is seeded from the audit log.
- **Two transports.** stdio (best over SSH) and Streamable HTTP. The HTTP transport binds to loopback, requires a bearer token and refuses public binds by default.
- **Real host metrics.** CPU, memory, load and per-process CPU and RSS are read straight from `/proc`, with no `psutil` dependency.
- **Small footprint.** Pure Python 3.10+ with three dependencies: `mcp`, `pydantic` and `PyYAML`.
## Quick start
On the server (Ubuntu or Debian, any systemd distro):
```bash
# 1. Install (pipx keeps it isolated; plain pip works too)
pipx install git+https://github.com/mohammadhadijavidi/mcp-vps-toolkit
# or: pip install git+https://github.com/mohammadhadijavidi/mcp-vps-toolkit
# 2. Write a policy (starts read-only) and edit the allowlists
sudo mkdir -p /etc/mcp-vps-toolkit
mcp-vps-toolkit init | sudo tee /etc/mcp-vps-toolkit/policy.yaml >/dev/null
sudo chmod 644 /etc/mcp-vps-toolkit/policy.yaml
# 3. Check what the AI will be allowed to do
mcp-vps-toolkit check
mcp-vps-toolkit tools
# 4. Try a tool locally (goes through the real MCP server, and is audited)
mcp-vps-toolkit call system_overview
mcp-vps-toolkit call service_logs service=nginx lines=50 since=1h grep=error
```
On your laptop, connect Claude Desktop (or any MCP client) over SSH. The SSH session is the transport, so no port is opened. This is `examples/claude_desktop_config.json`:
```json
{
"mcpServers": {
"my-vps": {
"command": "ssh",
"args": ["-T", "-o", "BatchMode=yes", "mcp@my-vps.example.com",
"/home/mcp/.local/bin/mcp-vps-toolkit", "serve",
"--policy", "/etc/mcp-vps-toolkit/policy.yaml"]
}
}
}
```
With Claude Code:
```bash
claude mcp add my-vps -- ssh -T mcp@my-vps.example.com /home/mcp/.local/bin/mcp-vps-toolkit serve
```
To try it without a server, run the demo. It starts the real server over stdio in a temporary sandbox and makes a fixed sequence of calls; no AI model is involved:
```bash
git clone https://github.com/mohammadhadijavidi/mcp-vps-toolkit && cd mcp-vps-toolkit
pip install -e . && python examples/demo_session.py
```
## Configuration
Everything lives in one policy file. Unknown keys are an error, so a typo cannot silently widen access.
```yaml
version: 1
mode: read-only # or read-write
dry_run: false # true = mutations always return a plan, never execute
use_sudo: false # prefix mutating systemctl with `sudo -n`
services:
read: [nginx, postgresql, "myapp-*.service"] # status + journald logs
restart: [myapp-api.service] # read-write mode only; implies read
logs:
files: [/var/log/nginx/*.log, /srv/myapp/logs/*.log]
max_lines: 300
max_bytes: 262144
redact: true
disk_paths: [/, /srv]
deploy:
targets:
api:
path: /srv/myapp
remote: origin
branch: main
restart: [myapp-api.service] # must also be in services.restart
limits:
mutating_per_hour: 10
command_timeout_s: 30
max_output_chars: 20000
```
| Key | Default | Meaning |
|---|---|---|
| `mode` | `read-only` | `read-write` registers `restart_service` and `deploy` |
| `dry_run` | `false` | `true` forces plan-only for every mutation |
| `use_sudo` | `false` | run mutating `systemctl` via `sudo -n` (see [Least privilege](#least-privilege-setup)) |
| `audit_log` | `~/.local/state/mcp-vps-toolkit/audit.jsonl` | append-only JSONL, mode `0600` |
| `services.read` / `services.restart` | empty | unit globs; bare names get `.service` |
| `logs.files` | empty | absolute file globs for `tail_log` |
| `disk_paths` | `[/]` | mount points reported by `disk_usage` |
| `deploy.targets` | empty | named git checkouts the AI may fast-forward |
| `limits.*` | see above | output caps, timeouts, mutation budget |
Environment variables (see `.env.example`):
| Variable | Purpose |
|---|---|
| `MCP_VPS_POLICY` | policy path (default `/etc/mcp-vps-toolkit/policy.yaml`) |
| `MCP_VPS_AUDIT_LOG` | override the audit log path |
| `MCP_VPS_TOKEN` | bearer token, required for `--transport http` (at least 24 characters) |
There is no LLM provider to configure. This is an MCP *server*, so the model runs in your client (Claude Desktop, Claude Code, Cursor, VS Code, or an agent built on the OpenAI Agents SDK or LangChain MCP adapters) and the server never calls a model API.
## Usage examples
### Tools
| Tool | Mode | What it does |
|---|---|---|
| `policy_info` | read | the effective allowlists and limits, so the model knows its boundaries |
| `system_overview` | read | hostname, uptime, CPU %, load, memory, swap, disks |
| `disk_usage` | read | usage for `disk_paths` |
| `top_processes` | read | top N by CPU or memory, with command lines redacted |
| `list_services` | read | allowlisted units and their state |
| `service_status` | read | `systemctl show` properties for one unit |
| `service_logs` | read | journald lines for a unit; `since`, `lines` and a substring `grep` |
| `tail_log` | read | last lines of an allowlisted log file |
| `deploy_status` | read | HEAD, branch and clean or dirty state of a deploy target |
| `audit_tail` | read | recent audit records |
| `restart_service` | **write** | restart an allowlisted unit (dry-run by default) |
| `deploy` | **write** | `git fetch` + `merge --ff-only`, then restart the target's units (dry-run by default) |
### Things to ask your assistant
- "Is anything on the server unhealthy? Check disk, memory and the services you can see."
- "nginx is returning 502s. Look at the last hour of nginx and myapp-api logs and tell me why."
- "Deploy the api target. Show me the plan first." The model calls `deploy(dry_run=true)`, shows you the fetch, merge and restart argv, and runs it only after you say yes.
### CLI
```bash
mcp-vps-toolkit serve # stdio (for SSH / local clients)
mcp-vps-toolkit serve --transport http # 127.0.0.1:8765/mcp, needs MCP_VPS_TOKEN
mcp-vps-toolkit check # validate policy, print effective permissions
mcp-vps-toolkit tools # list exposed tools (read vs WRITE)
mcp-vps-toolkit call restart_service service=myapp-api dry_run=false
mcp-vps-toolkit audit tail -n 50 --commands # who asked for what, and the exact argv that ran
mcp-vps-toolkit audit verify # check the hash chain
```
A denied call shows the model exactly which policy key blocked it:
```text
$ mcp-vps-toolkit call tail_log path=/etc/shadow
Error executing tool tail_log: DENIED by policy: log file '/etc/shadow' is not in the policy allowlist (logs.files)
```
## How it works

1. **Tool registry.** Only tools the mode allows are registered. In read-only mode there is no write tool to call or to trick the model into calling.
2. **Validation.** Strict patterns run before anything else. `nginx;reboot`, `--host=evil` and `$(id)` are rejected as invalid input before the policy check.
3. **Policy.** Deny by default, with glob allowlists per capability. `tail_log` checks both the requested path and its `realpath`.
4. **Dry-run and budget.** Mutations return a plan unless `dry_run=false` is passed and the policy allows execution. Executed mutations are rate-limited per hour.
5. **Runner.** The only code that starts processes. It accepts an argv allowlist only, `shell=False`, fixed `PATH`, minimal env, closed stdin and a timeout. `sudo` is accepted only as `sudo -n systemctl ...`.
6. **Redact and cap.** Secrets are masked and output is bounded before it reaches the model, which also reaches the provider's logs.
Every path, including errors, writes exactly one audit record.
## Security model and threat model
Security is the point of this project, so here is exactly what it does and does not protect against.
### Assets
The host itself, its secrets (credentials in logs, environment and config), service availability, and the integrity of deployed code.
### Adversaries considered
1. **Prompt injection.** Text the model reads (a log line, a commit message, a web page it browsed earlier) tells it to do something harmful.
2. **An over-eager or confused model** that restarts the wrong thing, deploys at the wrong time, or loops.
3. **A network attacker** reaching the HTTP transport.
4. **Secret exfiltration** through tool output that flows to the model provider and the chat transcript.
### Mitigations
| Threat | Mitigation |
|---|---|
| Arbitrary command execution | No shell or exec tool exists. Fixed argv, `shell=False`, a 4-program allowlist, `--` before every unit name, and strict regexes on all inputs. Tests prove shell metacharacters are inert. |
| Argument and flag injection | Unit names must start with an alphanumeric character. `since` is translated from a closed grammar. `grep` is a plain substring filter, never passed to a program. Git names are validated in the policy, not supplied by the model. |
| Reading sensitive files | `tail_log` accepts only absolute paths whose normalized path **and** symlink target both match `logs.files`. It opens with `O_NOFOLLOW` and requires a regular file, so FIFOs and devices are refused. |
| Unwanted changes | Read-only by default, with write tools not registered. Separate `restart` allowlist. `dry_run=true` by default plus a policy-level force switch. Per-hour budget. |
| Destructive deploys | Fast-forward only, refuses a dirty tree or wrong branch, `core.hooksPath=/dev/null`, `protocol.ext.allow=never`, and restarts only when HEAD changed, only allowlisted units. |
| Secret leakage to the model | Redaction of common token formats in logs, journald output, process command lines and audit args. Environment variables are never exposed. |
| Network access to the server | stdio over SSH is recommended. HTTP requires a 24+ character bearer token (constant-time compare), binds to `127.0.0.1` by default and refuses non-loopback binds without an explicit override. |
| Repudiation ("what did the AI do?") | Every call (allowed, denied, invalid, dry-run, executed, error) is audited with exact argv. The SHA-256 chain detects edited or deleted lines. |
| Loosened policy | Unknown keys and match-everything patterns are rejected. A warning is printed if the policy file is group- or world-writable. |
### Out of scope and known limits
- **A compromised host or server user.** If an attacker already runs code as the `mcp` user (or root), they can bypass this tool, edit the policy if it is writable, or rewrite the audit log. The hash chain detects edits in the middle of the log, not truncation of the tail or a full rewrite. Ship the audit log to another machine (journald forwarding, Vector or Fluent Bit) if you need stronger guarantees.
- **Redaction is best effort.** It uses pattern matching. A secret in an unusual format can get through. Keep high-secret files out of `logs.files` entirely.
- **Allowed actions can still cause harm.** Restarting an allowlisted service at a bad moment is still a restart. The policy limits *what* can happen; the dry-run plus your confirmation decides *when*. Keep `services.restart` short.
- **The model sees what you allow.** Log contents go to your model provider. Choose a provider and retention settings accordingly.
- **Deploy trusts the remote.** The deploy fast-forwards to whatever `origin/main` contains. Protect that branch, require reviews, and consider signed commits.
- **No sandbox.** The server is a normal Python process. Run it as a dedicated unprivileged user, optionally with the systemd hardening in `examples/mcp-vps-toolkit-http.service`.
- **Not run as root.** `check` warns if it is. Use the sudoers rule below instead.
### Least-privilege setup
```bash
sudo useradd --system --create-home --shell /bin/bash mcp
sudo usermod -aG systemd-journal,adm mcp # read journald + /var/log without root
# Only if mode: read-write and use_sudo: true. Restart exactly these units, nothing else:
sudo install -m 0440 examples/sudoers.mcp-vps-toolkit /etc/sudoers.d/mcp-vps-toolkit
sudo visudo -c
```
The sudoers rule matches the exact argv the tool runs (`systemctl --no-pager --no-ask-password restart -- <unit>`), so even a bug in this project could not use sudo for anything else. Keep `/etc/mcp-vps-toolkit/policy.yaml` owned by root and not writable by `mcp`.
## Roadmap
- [ ] Docker tools (`docker ps`, logs, restart of allowlisted containers) with the same policy model
- [ ] Human approval over Telegram or a webhook for `dry_run=false` calls (out-of-band confirmation)
- [ ] Ship audit records to syslog or an HTTP sink, and anchor the chain externally
- [ ] Nginx config test and reload (`nginx -t` gate) as a typed tool
- [ ] Certificate expiry and open-port checks
- [ ] Multi-host mode: one client, several policies
## Contributing
Issues and PRs are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first. The short version: new tools must take typed parameters (never a command), be deny-by-default in the policy, be dry-run-first if they mutate, and come with a test for the denial path.
```bash
pip install -e ".[dev]" && pytest && ruff check src tests examples
```
## License
[MIT](LICENSE) © 2026 Mohammad Hadi Javidi
---
Built by [Mohammad Hadi Javidi](https://github.com/mohammadhadijavidi) — AI products & automation, Dubai.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues