Skip to main content
Glama
0mandrock1

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.