A self-hosted Model Context Protocol server that gives AI assistants (Claude, Cursor, etc.) direct access to your Linux server — Docker containers, SSH execution, system health, logs, Nginx, Prometheus, and more.
Deploy it once on your server. Connect any MCP-compatible client.
Intended for trusted self-hosted environments. Binds to
127.0.0.1by default. Access to Docker socket and SSH gives the server significant power over your infrastructure — treat it accordingly.
| Tool | Description |
|---|---|
server_health |
CPU, memory, disk, uptime, Docker summary, failed systemd units |
system_info |
Detailed system info: hostname, platform, load avg, CPU freq |
docker_list |
List containers with status, ports, health |
docker_logs |
Fetch logs from a container (tail N lines) |
docker_inspect |
Full container inspect (config, mounts, network) |
docker_stats |
CPU/memory/network stats for running containers |
docker_control |
Start, stop, or restart a container |
ssh_exec |
Execute commands on remote hosts via SSH key |
file_get |
Read a remote file over SFTP — no shell, no quoting problems |
file_put |
Write a remote file over SFTP: diff preview, backup, preserved mode, config test with automatic rollback |
db_query |
Run SQL against PostgreSQL or MySQL; writes require confirmation |
ssh_sessions |
Inspect or close the SSH connections kept open between calls |
log_tail |
Read system log files (syslog, nginx, auth, etc.) |
nginx_test |
Run nginx -t config validation |
systemd_status |
Check status of systemd services |
tls_check |
Verify TLS certificate expiry and chain for a domain |
prometheus_query |
Run PromQL instant or range queries |
prometheus_targets |
List Prometheus scrape targets and their health |
search_web |
Web search via SerpAPI |
search_ai |
AI-powered technical search via Exa |
Security is built in, not bolted on:
- SSH commands are read-only by default — only safe, read-only commands allowed without
confirmed=true(uptime, df, cat, grep, journalctl,systemctl status,docker ps, etc.) - Conditionally safe commands —
sed,curl,wget,findallowed only without mutating flags;sed -i,curl -X POST,find -execrequireconfirmed=true - Log path allowlist —
log_tailonly reads from predefined safe paths - Nginx container allowlist —
nginx_testonly runs against approved container names - docker_control requires confirmation —
stopandrestartrequireconfirmed=true; AI must ask user before proceeding - file_put requires confirmation — writes need
confirmed=true;dry_run=truereturns the unified diff and touches nothing - Credential files are refused —
file_getandfile_putwill not openshadow,sudoers,authorized_keysor private keys - Tool annotations — every tool declares
readOnlyHint/destructiveHint, so the client can gate destructive calls by protocol rather than by trusting the model to read a description - Container runs as non-root —
mcpuser(UID 1000), all Linux capabilities dropped,no-new-privileges - SSH key path validation — only keys from
/app/keys/are accepted - TLS check port allowlist —
tls_checkonly connects to ports:80, 443, 465, 993, 995, 8080, 8443 - Audit log — every tool call is logged to
/audit/audit.jsonlwith timestamp, args and outcome (ok,refused,error)
- Docker + Docker Compose on the target server
- SSH access to the server
git clone https://github.com/CynepMyx/devops-mcp.git
cd devops-mcp
cp .env.example .envEdit .env:
SERPAPI_KEY=your_serpapi_key # optional, for search_web
EXA_API_KEY=your_exa_key # optional, for search_ai
DOCKER_GID=999 # match your server's docker group GID
PROTECTED_CONTAINERS=devops-mcp # comma-separated, cannot be stopped/restarteddocker compose up -dThe MCP server starts on 127.0.0.1:8765, serving streamable HTTP on /mcp and
legacy SSE on /sse.
Add to ~/.claude.json (or your Claude Desktop config):
{
"mcpServers": {
"devops": {
"type": "http",
"url": "http://YOUR_SERVER:8765/mcp"
}
}
}/mcp runs stateless: every request carries what it needs, so restarting the
container does not leave the client holding a dead session and answering
-32602 to every call. The older SSE transport is still mounted:
{ "type": "sse", "url": "http://YOUR_SERVER:8765/sse" }For remote servers, use an SSH tunnel:
ssh -L 8765:127.0.0.1:8765 user@your-serverThen use http://localhost:8765/sse.
Place your private key in the keys/ directory:
cp ~/.ssh/id_ed25519 keys/my-server.pem
chmod 600 keys/my-server.pemThen use in ssh_exec:
ssh_exec(host="10.0.0.5", user="deploy", key="/app/keys/my-server.pem", command="uptime")
Strict checking turns itself on as soon as there is something to check against: if
/app/ssh/known_hosts is non-empty, unknown hosts are rejected. While the file is empty
— a fresh install — connections run in warn mode instead, since rejecting every host
would only teach you to ignore the warning. SSH_STRICT_HOST_KEY=false forces warn mode
back, and verify_host_key on a single call overrides either way.
Getting the keys in there is the part worth doing carefully:
# On the server itself, over an access you already trust
cat /etc/ssh/ssh_host_ed25519_key.pub
# then add "10.0.0.5 ssh-ed25519 AAAA..." to /opt/devops-mcp/ssh/known_hostsssh-keyscan is the convenient way and the weaker one: it trusts whoever answers on the
port, which is exactly what host key checking exists to catch. Read the key from the
machine through access you already have, or from the provider's console.
The
known_hostsfile is mounted read-only into the container and gitignored — it never ends up in source control.
ssh_exec, file_get and file_put share one live SSH connection per host and
credential instead of authenticating on every call. Measured against a real host, the
first call takes 220 ms and the ones after it 50 ms, and a twenty step diagnostic opens
one connection instead of twenty. That second part matters as much as the speed: a burst
of connections from one address is exactly what fail2ban is built to notice.
The pool covers connections this server opens itself, including the ones it opens through
a jump host. A second hop the container does not control — an ssh or sshpass call
inside the command text — is still a fresh connection every time.
Pass jump_host and the credentials for it, and the connection is tunnelled:
ssh_exec(host="10.0.0.5", user="deploy", password="...",
jump_host="203.0.113.9", jump_user="ops", jump_key="/app/keys/bastion.pem",
command="uptime")
The jump host only carries bytes. Authentication with the target happens end to end
inside the tunnel, so the target's password is never an argument to a command running on
the intermediate box, where anyone with ps can read it. This is the same thing OpenSSH
does with ssh -J.
The alternative — running ssh or sshpass as a command on the jump host — costs more
than it looks: the password lands in the process list, output travels through a second
shell with a second round of quoting, the connection is outside the pool, and file_get
and file_put cannot reach the target at all, because SFTP does not fit inside a command
string. With jump_host all of that works normally.
Jump connections are pooled and shared: ten client servers behind one bastion open one
connection to the bastion. Closing it with ssh_sessions closes what rides on it, and
the reaper leaves a jump host alone while anything is tunnelled through it.
Commands still run as separate channels, so nothing carries over between them: no working directory, no environment, no shell state. That is on purpose. A persistent shell would let two separately validated calls add up to one command that neither of them was.
Connections close themselves after five minutes idle, and ssh_sessions shows what is
open or closes it now:
ssh_sessions(action="status")
ssh_sessions(action="close", host="10.0.0.5")
Credentials never share a connection: the pool key includes host, port, username and
which key or password was used, and a password is stored only as a hash. Set
SSH_POOL=false to go back to connecting every time.
ssh_exec runs everything through a shell, so writing a config with it means fighting
quoting rules and a 500 character ceiling. file_get / file_put use SFTP instead —
the bytes travel as bytes.
file_put(host="10.0.0.5", user="deploy", key="/app/keys/my-server.pem",
path="/etc/nginx/conf.d/site.conf", content="...", dry_run=true)
The dry run returns a unified diff and writes nothing. Repeat with confirmed=true to
apply it: the previous version is saved as site.conf.bak_<timestamp>, mode and owner
are carried over, and the new content lands via a temporary file in the same directory,
so a reader never sees a half-written config. A file that does not exist yet needs an
explicit mode, because guessing 0644 on a config is how secrets end up world-readable.
A broken config is harmless right up until something reloads it. Pass verify_cmd and
the check runs against what was just written:
file_put(host="10.0.0.5", user="deploy", key="/app/keys/my-server.pem",
path="/etc/nginx/conf.d/site.conf", content="...",
verify_cmd="nginx -t", confirmed=true)
If the test exits non-zero, the previous content is restored, the same test is run again
to prove the server is back where it started, and the now-pointless backup is removed.
The response carries verify, rolled_back and verify_after_rollback, so the failure
is visible rather than inferred. Set rollback_on_failure=false to keep the new content
anyway.
verify_cmd accepts config tests only (nginx -t, apachectl configtest, sshd -t,
php -l, named-checkconf, haproxy -c, systemd-analyze verify, docker compose config, and similar). It runs automatically as part of a write, so it has to be a test
and not a general shell; anything else belongs in ssh_exec, where it shows up as its
own call.
Once connected, you can ask your AI assistant things like:
- "Check server health and show me any failed services"
- "Restart the nginx container and verify config is valid"
- "Show last 50 lines from the nginx access log"
- "Is the TLS cert for example.com still valid?"
- "Run a Prometheus query for 5-minute CPU usage"
- "SSH into 10.0.0.5 as deploy and check disk usage"
Claude / Cursor / any MCP client
| SSE (HTTP)
v
FastAPI + MCP Server <---- security.py (validation layer)
|
+-----+------+----------+-----------+----------+
| | | | |
Docker SDK Paramiko psutil/dbus httpx Prometheus
(local) (SSH) (system) (HTTP) API
| Variable | Default | Description |
|---|---|---|
MCP_HOST |
0.0.0.0 |
Bind address inside container |
MCP_PORT |
8765 |
Port |
AUDIT_LOG_PATH |
/audit/audit.jsonl |
Audit log location |
PROTECTED_CONTAINERS |
devops-mcp |
Containers immune to start/stop/restart |
SERPAPI_KEY |
— | SerpAPI key for search_web |
EXA_API_KEY |
— | Exa key for search_ai |
PROMETHEUS_URL |
http://host.docker.internal:9090 |
Prometheus endpoint |
ALLOW_SSH_PASSWORD |
false |
Enable SSH password auth (key-based is default) |
SSH_STRICT_HOST_KEY |
auto | Reject unknown host keys. Defaults to on when known_hosts is non-empty, off when it is empty |
SSH_POOL |
true |
Keep SSH connections open between calls; false reconnects every time |
SSH_POOL_IDLE_TTL |
300 |
Close a pooled connection after this many idle seconds |
SSH_POOL_MAX |
20 |
Maximum pooled connections before the least recently used one is dropped |
SSH_POOL_KEEPALIVE |
30 |
Keepalive interval, so an idle connection is not dropped silently |
DEV_HOT_RELOAD |
false |
Enable live tool file-watching (dev only) |
DevOps MCP is designed for trusted self-hosted environments. Read SECURITY.md for the full threat model.
Key constraints:
- Binds to
127.0.0.1by default — not exposed to the internet - Docker socket access is intentional and powerful — treat the endpoint accordingly
- SSH keys are validated against a path allowlist (
/app/keys/) - Destructive actions (
stop,restart, dangerous shell commands) require explicitconfirmed=true - Every tool call is appended to
/audit/audit.jsonl
MIT