mcp-vpsobs
by 0mandrock1
README.md
# mcp-vpsobs
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, credentials embedded in connection strings
(`scheme://user:pass@host`), PEM private-key headers 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`, no `systemctl restart`, no `docker exec` for
it to reach for. The collectors in this repo only ever call `backend.run`
with a fixed, read-only argv or `backend.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_path` and `resolve_directory` raise
`DeniedError` before a collector builds an argv or opens a file --
`tests/test_attack.py` proves 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_tail` in 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=value` assignments, 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 `/` in `directories`
or a unit that happens to log credentials in `units`, 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`, `nginx` and `openssl`
are. A bug in one of those binaries, or in the `mcp` SDK 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` (in `src/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
asking `Config`.
- **Backend injection.** Every collector takes a `Backend` -- `run(argv,
timeout, max_bytes)` or `read_file(path, max_bytes, tail)` -- so tests run
against a `FakeBackend` fed canned command output. No test in this repo
touches a real `systemctl`, `docker`, `journalctl`, `nginx` or `openssl`.
- **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 to `host://services` and
`journal_tail`.
- `containers` -- docker container names visible to `host://containers` and
`docker_logs`.
- `sites` -- `site label -> access log path`, used by `nginx_access_stats`.
- `directories` -- absolute directory roots `disk_hogs` may inspect.
- `certs` -- certificate file paths `cert_expiry` reports 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 if `nginx -T` is 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-level `mcp.server.Server`
API, for less boilerplate per resource/tool.
- **Error reporting**: `VpsobsError` subclasses are caught in `server.py` and
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://containers` filtering**: shows only containers already on the
`containers` allowlist, 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_map` and the unit allowlist**: `port_map` does not cross-reference
listening sockets against `config.units`. It reports whatever `ss`
reports; nothing is denied either way, so filtering would only be
cosmetic. Note `ss -tulpn` needs to run as root (or with equivalent
capabilities) to populate the process/pid column at all -- without that,
`process`/`pid` come back `null`.
- **`host://sites` directive scoping**: the nginx config parser tracks
`server { ... }` blocks (including nested `location` blocks) for
`server_name`, `root` and `proxy_pass`, but does not model nginx's
directive-inheritance rules (e.g. a `root` set on the surrounding `http`
block applying to a server that declares none itself).
- **Config file location**: `--config` defaults to `./config.yaml` in the
current working directory; there is no `/etc/vpsobs/config.yaml` fallback.
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 `--config` path either way.
- **No audit logging**: confirmed absent by reading `server.py` and every
collector -- no tool call is logged anywhere beyond whatever the `mcp`
library itself does. Stated explicitly in "Why not just give the agent
SSH" above rather than left implicit.
- **`journal_tail`/`docker_logs` `grep`**: implemented as a plain Python
substring filter applied to lines already returned by `journalctl`/`docker
logs`, never passed to a subprocess or treated as a regex -- there is no
way for a `grep` value to become a shell metacharacter problem because it
never reaches an argv.
## Running it
```bash
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:
```bash
claude mcp add vpsobs -- mcp-vpsobs --config /path/to/config.yaml
```
or, in Claude Desktop's `claude_desktop_config.json`:
```json
{
"mcpServers": {
"vpsobs": {
"command": "mcp-vpsobs",
"args": ["--config", "/path/to/config.yaml"]
}
}
}
```
## Testing
```bash
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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues