Vpsobs
FreeNot checkedEnables read-only observability of a Linux host via MCP, exposing allowlisted systemd, docker, nginx, logs, disk, and cert info without shell access.
About
Enables read-only observability of a Linux host via MCP, exposing allowlisted systemd, docker, nginx, logs, disk, and cert info without shell access.
README
An AI agent that is troubleshooting a production incident wants to look at
logs, check whether a service is up, and see how full a disk is. The usual
way to give it that is to hand it a shell: SSH credentials, or a "run this
command" tool with a string it can pass to subprocess. That solves the
immediate problem and creates a much bigger one -- the agent (and anything
that can make it say the wrong thing, from a poisoned log line to a
compromised MCP client) now has full read/write access to a host that keeps
other people's systems running.
mcp-vpsobs is a Model Context Protocol server that gives an agent eyes on
a single Linux host without giving it hands. It does not accept a command
string. It does not have a write, restart, kill or exec path anywhere in the
code. Every unit, container, log and directory it can see is named ahead of
time in a YAML allowlist; anything not named is denied before a subprocess
is even started. Every byte it returns has already passed through a
redaction pass that strips bearer tokens, password= assignments,
provider-shaped API keys and e-mail addresses.
Why not just give the agent SSH
The honest argument for a narrower surface:
- The blast radius of a mistake is bounded. If the agent (or the model
behind it, or a prompt-injected log line it read) decides to do something
destructive, there is no
rm, nosystemctl restart, nodocker execfor it to reach for. The collectors in this repo only ever callbackend.runwith a fixed, read-only argv orbackend.read_file; there is no code path that writes to the host. - The allowlist is explicit and inspectable. A YAML file is the entire answer to "what can this agent see on my host", instead of "whatever the SSH key's permissions and the model's judgement allow at any given moment."
- Denial happens before any host access.
Config.check_unit,check_container,site_log_pathandresolve_directoryraiseDeniedErrorbefore a collector builds an argv or opens a file --tests/test_attack.pyproves this with a backend fixture that fails the test if it is ever called. - Secrets get a chance to not leak. Every line handed back to the agent
passes through
redact().
What this design does not protect against, stated plainly rather than hand-waved:
- Resource exhaustion by a well-behaved-looking client. Each call has a
timeout and a byte cap, but nothing here rate-limits repeated calls. An
agent that calls
journal_tailin a tight loop can still generate load proportional to how fast it can issue MCP requests. There is no token-bucket or per-minute quota in this codebase. - Novel secret shapes. The redaction patterns are shaped (bearer tokens,
key=valueassignments, known provider key formats, e-mails, optionally private IPv4) rather than a generic entropy detector, on purpose -- a blanket "redact anything that looks random" rule would also eat git SHAs, UUIDs and request IDs and make the logs useless. A secret in a format none of the patterns anticipate will pass through unredacted. - An operator who allowlists too much. If you put
/indirectoriesor a unit that happens to log credentials inunits, this server will faithfully show an agent everything inside it. The allowlist moves the trust decision to the config file; it does not make the decision for you. - Vulnerabilities in the tools it shells out to. This server is only as
read-only as
systemctl,journalctl,docker,nginxandopensslare. A bug in one of those binaries, or in themcpSDK itself, is outside what an allowlist can fix. - Audit logging. There is no record kept of which tool was called with which arguments. Everything here is visibility of the host, not visibility into how the agent used this server. If you need "who asked what and when," it is not implemented -- add logging in front of the transport before relying on this in a setting where that matters.
Architecture
config.yaml --> Config (allowlist + limits)
|
v
collectors/* --uses--> Backend (Protocol)
| |
v +-- SubprocessBackend (real: argv-only, shell=False)
redact() / redact_lines() +-- FakeBackend (tests: canned argv -> CommandResult)
|
v
server.py (FastMCP resources + tools, stdio)
- Allowlist first.
Config(insrc/vpsobs/config.py, not modified by this change) is the only place that knows what is visible. A collector cannot "forget" a check because there is nothing for it to check except by askingConfig. - Backend injection. Every collector takes a
Backend--run(argv, timeout, max_bytes)orread_file(path, max_bytes, tail)-- so tests run against aFakeBackendfed canned command output. No test in this repo touches a realsystemctl,docker,journalctl,nginxoropenssl. - Redaction is a pipeline, not a suggestion. Anything that reaches an
agent goes through
redact()/redact_lines()on the way out.
Resources
| URI | Contents |
|---|---|
host://summary |
uptime, load average, memory, disk usage of / |
host://services |
allowlisted systemd units with active/sub/load state |
host://containers |
allowlisted docker containers (name, image, status, ports) |
host://sites |
nginx server_name -> root/proxy_pass |
Tools
| Tool | Signature |
|---|---|
journal_tail |
(unit, lines=100, since=None, grep=None) |
docker_logs |
(container, lines=100, since=None) |
nginx_access_stats |
(site, window="1h") -- request count, status histogram, top paths, top user agents, error sample |
cert_expiry |
() -- days remaining per configured certificate |
disk_hogs |
(path, depth=1, top=10) |
port_map |
() -- listening sockets with owning process, where known |
Every tool result is a JSON string. Failures (denied, not configured,
timeout, backend error) come back as {"error": ..., "message": ..., "details": {...}} in the same shape, rather than as a different kind of MCP
protocol error -- see "Design notes" below.
Configuration
Copy config.example.yaml to config.yaml and edit it:
units-- systemd unit names visible tohost://servicesandjournal_tail.containers-- docker container names visible tohost://containersanddocker_logs.sites--site label -> access log path, used bynginx_access_stats.directories-- absolute directory rootsdisk_hogsmay inspect.certs-- certificate file pathscert_expiryreports on.redaction.redact_private_ipv4-- also scrub RFC1918/loopback addresses (off by default).limits.timeout_seconds/limits.max_output_bytes/limits.max_lines-- the hard ceiling every collector runs under.nginx_config_command-- override ifnginx -Tis not the right command for your setup (default["nginx", "-T"]).
Design notes
Places where the spec left a decision open and the simpler option was taken:
- MCP SDK surface: built on
mcp.server.fastmcp.FastMCP(decorator-based resources/tools, stdio transport) rather than the low-levelmcp.server.ServerAPI, for less boilerplate per resource/tool. - Error reporting:
VpsobsErrorsubclasses are caught inserver.pyand serialized as{"error": ...}JSON text in the normal tool/resource result, rather than raised as MCP protocol-level errors. One response shape for both success and failure is simpler for a caller to parse. host://containersfiltering: shows only containers already on thecontainersallowlist, rather than showing every running container annotated with an allowlisted/not flag. Fewer things for an agent to see that it can't act on, and no leaking of the existence of containers the operator never chose to expose.port_mapand the unit allowlist:port_mapdoes not cross-reference listening sockets againstconfig.units. It reports whateverssreports; nothing is denied either way, so filtering would only be cosmetic. Notess -tulpnneeds to run as root (or with equivalent capabilities) to populate the process/pid column at all -- without that,process/pidcome backnull.host://sitesdirective scoping: the nginx config parser tracksserver { ... }blocks (including nestedlocationblocks) forserver_name,rootandproxy_pass, but does not model nginx's directive-inheritance rules (e.g. arootset on the surroundinghttpblock applying to a server that declares none itself).- Config file location:
--configdefaults to./config.yamlin the current working directory; there is no/etc/vpsobs/config.yamlfallback. Simpler to reason about, and the caller (Claude Desktop config,claude mcp add, systemd unit, etc.) already has to specify a working directory or an absolute--configpath either way. - No audit logging: confirmed absent by reading
server.pyand every collector -- no tool call is logged anywhere beyond whatever themcplibrary itself does. Stated explicitly in "Why not just give the agent SSH" above rather than left implicit. journal_tail/docker_logsgrep: implemented as a plain Python substring filter applied to lines already returned byjournalctl/docker logs, never passed to a subprocess or treated as a regex -- there is no way for agrepvalue to become a shell metacharacter problem because it never reaches an argv.
Running it
pip install -e .
cp config.example.yaml config.yaml # then edit the allowlist for your host
mcp-vpsobs --config config.yaml
The server speaks MCP over stdio. To point Claude Desktop or claude mcp add at it:
claude mcp add vpsobs -- mcp-vpsobs --config /path/to/config.yaml
or, in Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"vpsobs": {
"command": "mcp-vpsobs",
"args": ["--config", "/path/to/config.yaml"]
}
}
}
Testing
pip install -e ".[dev]"
pytest -q
Every test runs against a fixture backend (tests/conftest.py's
FakeBackend) or a pure string-parsing helper -- none of them shell out to a
real systemctl, journalctl, docker, nginx or openssl, so the suite
passes in CI (see .github/workflows/ci.yml) with none of those installed.
As of this commit the suite defines 72 test functions across 13 files
(grep -roh "def test_" tests/ | wc -l); parametrized cases push the number
pytest actually collects higher than that -- run pytest -q for the exact,
current count.
Install Vpsobs in Claude Desktop, Claude Code & Cursor
unyly install mcp-vpsobsInstalls into Claude Desktop, Claude Code, Cursor & VS Code — handles npx, uvx and build-from-source repos for you.
First time? Get the CLI: curl -fsSL https://unyly.org/install | sh
Or configure manually
Run in your terminal:
claude mcp add mcp-vpsobs -- uvx --from git+https://github.com/0mandrock1/mcp-vpsobs mcp-vpsobsStep-by-step: how to install Vpsobs
FAQ
Is Vpsobs MCP free?
Yes, Vpsobs MCP is free — one-click install via Unyly at no cost.
Does Vpsobs need an API key?
No, Vpsobs runs without API keys or environment variables.
Is Vpsobs hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Vpsobs in Claude Desktop, Claude Code or Cursor?
Open Vpsobs on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
GitHub
PRs, issues, code search, CI status
by GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
by mcpdotdirectCompare Vpsobs with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All development MCPs
