Skip to main content
Glama

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.

CI License: MIT Python MCP

mcp-vps-toolkit: a policy file next to real terminal output showing denied, invalid and dry-run calls

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.


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

Related MCP server: vps-ops-mcp

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):

# 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:

{
  "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:

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:

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.

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)

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

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:

$ 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

Request flow: client to six gates (tool registry, validation, policy, dry-run and budget, runner, redaction), with denied, plan and audit outcomes

  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

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 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.

pip install -e ".[dev]" && pytest && ruff check src tests examples

License

MIT © 2026 Mohammad Hadi Javidi


Built by Mohammad Hadi Javidi — AI products & automation, Dubai.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    Enables AI assistants to perform controlled Linux system administration tasks like reading logs, managing services, cron jobs, WordPress, and executing sandboxed Python code, with strict security constraints.
    29
    2
    GPL 2.0
  • A
    license
    A
    quality
    A
    maintenance
    A safe, structured MCP server that lets AI inspect and operate a VPS through typed, allowlisted tools for nginx, PM2, SSL, UFW, fail2ban, with read-only defaults and opt-in mutations.
    6
    14 npm
    2
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI tools to securely inspect remote servers by listing directories, reading configuration files, and performing authorized actions through permission controls, human-in-the-loop approvals, and audit logging. It acts as a bastion that never reveals raw SSH credentials or root passwords to the model.
    1
    MIT
  • A
    license
    Not graded
    quality
    F
    maintenance
    Enables AI clients to operate remote VPS hosts through a jump-host gateway with a fixed whitelist of tools, allowing them to read logs, inspect and edit configuration files, manage services, and deploy or roll back versions. Production writes require a per-client time-limited lease, and all actions are audited, verified, and automatically reverted on failure.
    MIT