Skip to content

Installation

Requirements

  • Local (where the MCP client runs): Python 3.11+, uv (or pip), OpenSSH client.
  • Remote (each ZFS host you want to explore): Python 3.11+, OpenSSH server, SSH key authentication (preferably via agent forwarding), and the user account you SSH in as must either hold the delegated diff permission on the relevant pools (user mode) or have passwordless sudo (sudo mode).

Install the server locally

From source

git clone https://github.com/hamsolodev/zsnoop-mcp.git
cd zsnoop-mcp
uv sync

Run it with uv run zsnoop-mcp.

From PyPI

uv tool install zsnoop-mcp   # or: pip install zsnoop-mcp

Run it with zsnoop-mcp. See PUBLISHING.md for the release flow.

Choose a privilege mode (per host)

For each host you configure, decide whether the remote agent runs as your SSH user (default) or under sudo as root (opt-in per host).

User mode (default) Sudo mode
Privilege normal user account root
zfs diff works? only with delegated diff permission (below) yes
Read files in system datasets (rpool/ROOT/…)? only files the user can read any file
Setup on remote grant zfs allow … diff once per pool passwordless sudo (e.g. pam_ssh_agent_auth + agent forwarding)
Trust footprint minimal root on the host

User mode is recommended unless you specifically need to read root-owned files from snapshots. The threat model in SECURITY.md discusses the tradeoff in detail.

Passwordless sudo for sudo mode

In sudo mode, the SSH user must be able to run sudo python3 … (bootstrap mode) or sudo /path/to/zfs-snoop-agent (preinstalled mode) without a password prompt. The recommended mechanism is pam_ssh_agent_auth with SSH agent forwarding — the remote sudo verifies your forwarded key and grants elevation without an interactive prompt.

ZFS delegated permissions (user mode only)

zfs diff requires either root or the diff delegated permission. Everything else zsnoop-mcp does — listing snapshots, walking .zfs/snapshot/ directories, reading files — uses default-allowed zfs subcommands or normal POSIX file access governed by the owner of the SSH session.

For each pool you want to be able to diff, on each host:

sudo zfs allow -u $USER diff <pool>
# example: a Debian-on-ZFS box with the canonical layout
sudo zfs allow -u $USER diff rpool
sudo zfs allow -u $USER diff bpool

Verify with:

zfs allow rpool

This is all that's delegated. No snapshot, no destroy, no mount, no send. The agent refuses anything outside its explicit method allowlist regardless of permissions held, but minimising delegated rights is defence in depth.

What this does not grant

  • Reading files inside snapshots is still governed by POSIX permissions. If you can't read /etc/shadow on the live filesystem, you can't read it from a snapshot either.
  • The agent never modifies snapshots, pools, or filesystems. The diff delegation confers no write capability.

Remote agent deployment

Two modes; pick per host (or mix).

Bootstrap-on-connect (zero install on remote)

The local server streams agent/zfs_snoop_agent.py (≈26 KB) over the SSH connection on first use. No file is left on the remote. Best ergonomics during development — change the agent locally and the next call uses the new version.

This is the default (agent_mode = "bootstrap") and requires no remote-side setup beyond Python 3.11+.

Pre-installed (slightly lower per-session cost)

# from your local checkout
scp agent/zfs_snoop_agent.py <host>:~/bin/zfs-snoop-agent
ssh <host> chmod +x ~/bin/zfs-snoop-agent

Then set agent_mode = "preinstalled" and agent_path = "~/bin/zfs-snoop-agent" for that host in your config. Saves ~30 KB of source transfer per session.

Host configuration

The MCP server looks for hosts.toml in this order:

  1. $ZSNOOP_CONFIG (if set)
  2. $XDG_CONFIG_HOME/zsnoop-mcp/hosts.toml
  3. ~/.config/zsnoop-mcp/hosts.toml

A minimal example:

[hosts.r2d2]
ssh_target = "r2d2.example.com"
agent_mode = "bootstrap"
sudo       = false
pools      = ["rpool", "bpool"]

[hosts.c3po]
ssh_target    = "c3po.example.com"
agent_mode    = "preinstalled"
agent_path    = "/home/youruser/bin/zfs-snoop-agent"
sudo          = true
remote_python = "python3"
ssh_options   = ["-o", "ConnectTimeout=5"]
pools         = ["rpool"]

Per-host fields:

Field Default Description
transport "ssh" "ssh" (remote) or "local" (no SSH, agent runs on this machine).
ssh_target (required if transport="ssh") What gets passed to ssh, e.g. user@host.
agent_mode "bootstrap" "bootstrap" or "preinstalled".
agent_path (required for preinstalled) Absolute path to the agent script.
sudo false Run the agent under sudo (needs passwordless setup).
remote_python "python3" Interpreter to use in bootstrap mode.
ssh_options [] Extra args inserted between ssh defaults and target. Ignored when transport="local".
pools [] Hint to the LLM about which pools exist (optional — use the list_pools tool for live discovery).
allow_restore false Opt-in to the writable restore_file / restore_dir tools on this host (v0.4.0+). See below.
restore_paths [] Absolute path prefixes restores may write under. Required (non-empty) when allow_restore = true.

pools is metadata only at this layer; the agent itself queries whichever datasets it has permission to see.

Enabling restore (v0.4.0+)

The restore_file / restore_dir tools write to the host's live filesystem and are the only mutating operations the server exposes. They are disabled per host by default; an existing config from earlier zsnoop-mcp releases stays strictly read-only after upgrade.

To enable, set both keys on the host:

[hosts.bork]
ssh_target     = "bork.lan"
sudo           = false                         # or true to restore root-owned files
allow_restore  = true                          # opt in
restore_paths  = ["/srv/", "/home/mch/"]       # required: at least one absolute prefix

restore_paths is the operator's allowlist of destinations a restore may land in: any target_path passed to restore_file / restore_dir must canonicalise (Path.resolve) to a path under one of these prefixes. Entries are trailing-slash normalised on load, so /home/mch and /home/mch/ are equivalent. Errors at load time:

  • allow_restore = true with an empty (or missing) restore_paths is rejected — opt-in is paired with mandatory scope.
  • Each restore_paths entry must be an absolute path (start with /).
  • Empty / whitespace-only entries are rejected (they would otherwise normalise to "/" and silently widen the allowlist to the whole filesystem).

The denylist /proc/, /sys/, /dev/, and anything containing /.zfs/snapshot/ is always refused regardless of the allowlist. To restore root-owned files (e.g. anything under /etc/), pair allow_restore = true with sudo = true — restore inherits the same elevated execution mode as the read methods. See SECURITY.md G7 for the full validation flow and threat model.

Local mode (no SSH)

To run the agent on the same machine as the MCP server — useful if the machine itself has ZFS, or for testing — set transport = "local":

[hosts.this-box]
transport  = "local"
agent_mode = "bootstrap"   # still applies: bootstrap runs python3 -c …;
                           # preinstalled runs the agent script directly
sudo       = false         # set true to read root-owned snapshot files

ssh_target is not required in local mode (any value is ignored). All other fields (agent_mode, agent_path, sudo, remote_python) behave the same; SSH-specific fields (ssh_options) are ignored.

Wire into Claude Code

Add to ~/.claude/settings.json:

{
  "mcpServers": {
    "zsnoop": {
      "command": "uv",
      "args": ["run", "--directory", "/home/youruser/Documents/worktrees/zsnoop-mcp", "zsnoop-mcp"]
    }
  }
}

(After uv tool install zsnoop-mcp you can just use "command": "zsnoop-mcp" with no args.)

Restart your Claude Code session; the tools appear under the zsnoop namespace.

Environment requirements

The MCP server spawns ssh and relies on agent forwarding via SSH_AUTH_SOCK. Some MCP clients (notably mcp.client.stdio from the SDK, used in scripts and tests) strip env vars by default — they only pass HOME, LOGNAME, PATH, SHELL, TERM, USER. If your client does this, ssh will fail immediately with BatchMode=yes and you'll see a transport error citing the agent's stderr.

Claude Code itself passes the user's env through to spawned MCP servers, so no special config is usually needed. If you hit "agent unreachable" errors that mention publickey/permission failures, ensure your client passes SSH_AUTH_SOCK:

"env": {
  "SSH_AUTH_SOCK": "/path/to/your/ssh-agent.socket"
}

The server logs a warning at startup if SSH_AUTH_SOCK is unset while hosts are configured.

Verify

A quick end-to-end check without spinning up an MCP client:

uv run python -c "
import asyncio
from zsnoop_mcp.config import load_config
from zsnoop_mcp.server import find_agent_source
from zsnoop_mcp.transport import ConnectionPool
async def go():
    cfg = load_config('/home/youruser/.config/zsnoop-mcp/hosts.toml')
    async with ConnectionPool(cfg, find_agent_source()) as p:
        print(await p.call('r2d2', 'agent_info'))
asyncio.run(go())
"