
# pihole-mcp
A production-grade [MCP](https://modelcontextprotocol.io/) server for [Pi-hole](https://pi-hole.net/) v6.
**82 tools** (84 with a second instance) | **9 prompts** | **5 resources** | Multi-instance + sync | Read-only mode + toolsets | Single Go binary
[](https://github.com/hexamatic/pihole-mcp/actions/workflows/ci.yml)
[](https://codecov.io/gh/hexamatic/pihole-mcp)
[](https://scorecard.dev/viewer/?uri=github.com/hexamatic/pihole-mcp)
[](https://pkg.go.dev/github.com/hexamatic/pihole-mcp)
[](LICENSE)
Gives AI assistants full control over your Pi-hole instance: DNS blocking, domain management, query analysis, statistics, network devices, DHCP, and system administration. Compatible with the Pi-hole v6 REST API.
## Why this one
- **Run it locked down.** `PIHOLE_READ_ONLY=true` exposes only the tools that cannot change anything, enforced on every call, not just the tool list a client happens to cache. `PIHOLE_TOOLSETS` narrows further to just the families you need, so a client asking "what can I use" gets a smaller, cheaper answer.
- **82 tools** (84 once a second Pi-hole is configured) covering roughly 95% of the Pi-hole v6 REST API, including domains, lists, groups, clients, DHCP, network, the query log, configuration, local DNS records, teleporter backup and restore, and session management.
- **Multi-instance diff and sync.** Compare two Pi-holes and see exactly what differs, or reconcile one onto the other with a dry-run plan and a confirm token before anything is written.
- **Releases are signed and attested, not just built.** Every archive and container image carries a keyless cosign signature, an SPDX SBOM and SLSA build provenance, and [SECURITY.md](SECURITY.md) has the one-line command to check each.
- **Listed in the official [MCP Registry](https://registry.modelcontextprotocol.io/)** as `io.github.hexamatic/pihole-mcp`, installable by name from any client that supports registry install.
- **Shipped nine releases in five months** since launch, each with real release notes describing what changed and why, not a commit-hash dump.
## Quick Start
Most MCP clients use the same configuration format. Add this to your client's config:
```json
{
"mcpServers": {
"pihole": {
"command": "pihole-mcp",
"env": {
"PIHOLE_URL": "http://192.168.1.2",
"PIHOLE_PASSWORD": "your-password"
}
}
}
}
```
Then install the binary via one of the methods below.
## What you can ask
The whole point is natural language, not tool names. A few examples of what to actually type:
| You ask | What runs |
|---|---|
| "Is Pi-hole blocking right now, and for how long?" | `pihole_dns_get_blocking` |
| "Pause blocking for 10 minutes, I need to test something." | `pihole_dns_set_blocking` |
| "Why is reddit.com blocked? Check every list it could be on." | `pihole_search_domains` |
| "What are the top blocked domains today, and does anything look like a false positive?" | `pihole_stats_recent_blocked`, the `review_top_blocked` prompt |
| "Point nas.home at 192.168.1.50." | `pihole_local_dns_add` |
| "Which of my blocklists haven't updated in 30+ days?" | `pihole_lists_list` |
| "Show me devices on the network Pi-hole doesn't recognise." | `pihole_network_devices`, the `audit_network` prompt |
| "Compare my two Pi-holes and tell me what's different." | `pihole_instance_diff` |
| "Back up my configuration before I change anything." | `pihole_teleporter_export` |
| "Which upstream resolver is slowest, and is it worth switching?" | `pihole_stats_upstreams`, the `upstream_health` prompt |
Set `PIHOLE_READ_ONLY=true` first if you want to ask questions without any risk of something being
changed. See [Scoping the tool surface](#scoping-the-tool-surface).
## Installation
### MCP Registry
pihole-mcp is listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/) as:
```
io.github.hexamatic/pihole-mcp
```
Clients that support registry install can add it by that name and will prompt for
`PIHOLE_URL` and `PIHOLE_PASSWORD`. The listing points at the `ghcr.io` image, so the
client needs a working Docker.
### Homebrew
```bash
brew install hexamatic/tap/pihole-mcp
```
Installs on both macOS and Linux (Homebrew on Linux). On macOS the cask clears the
quarantine attribute during install, so the binary runs without a Gatekeeper prompt.
### Scoop (Windows)
```powershell
scoop bucket add hexamatic https://github.com/hexamatic/scoop-bucket
scoop install pihole-mcp
```
### Go Install
```bash
go install github.com/hexamatic/pihole-mcp/cmd/pihole-mcp@latest
```
### Docker
```bash
docker pull ghcr.io/hexamatic/pihole-mcp:latest
```
### Linux Packages
`.deb` and `.rpm` packages for Debian-based (Ubuntu, Raspberry Pi OS) and RPM-based (Fedora, RHEL) distributions are available on the [Releases](https://github.com/hexamatic/pihole-mcp/releases) page.
```bash
# Debian / Ubuntu / Raspberry Pi OS
sudo dpkg -i pihole-mcp_X.Y.Z_linux_amd64.deb
# Fedora / RHEL / CentOS
sudo rpm -i pihole-mcp_X.Y.Z_linux_amd64.rpm
```
### Binary Download
Pre-built binaries for Linux, macOS, and Windows (amd64 and arm64) are available on the [Releases](https://github.com/hexamatic/pihole-mcp/releases) page.
Releases are checksummed, signed with keyless cosign, and ship SPDX SBOMs and SLSA build provenance. See [SECURITY.md](SECURITY.md#verifying-release-artefacts) for the verification commands.
### Check your setup
Once installed and configured, run the server directly with `-check`. It loads the configuration, contacts every Pi-hole you have configured, and reports each one by name:
```bash
PIHOLE_URL=http://192.168.1.2 PIHOLE_PASSWORD=your-password pihole-mcp -check
```
```text
pihole-mcp 0.9.0
PASS primary (http://192.168.1.2) core v6.1.4, FTL v6.7
All 1 instance(s) reachable and authenticated.
```
It exits non-zero if any instance fails, and prints what to change. Worth running before you wire the server into a client: most clients hide the server's output, so a bad URL or password looks the same as a server that never started.
## Configuration
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `PIHOLE_URL` | Yes | - | Pi-hole base URL (e.g. `http://192.168.1.2`) |
| `PIHOLE_PASSWORD` | Yes | - | Admin password or [application password](https://docs.pi-hole.net/api/auth/) |
| `PIHOLE_READ_ONLY` | No | `false` | Expose only tools that cannot change Pi-hole. See [Scoping the tool surface](#scoping-the-tool-surface). |
| `PIHOLE_TOOLSETS` | No | all | Comma-separated toolset names to expose, e.g. `dashboard,domains`. Unset or `all` exposes everything. |
| `PIHOLE_REQUEST_TIMEOUT` | No | `30s` | HTTP request timeout |
| `PIHOLE_MAX_RETRIES` | No | `3` | Retries after a failed Pi-hole API call. `0` disables. |
| `PIHOLE_RETRY_MAX_DELAY` | No | `8s` | Upper bound on a single backoff wait. |
| `PIHOLE_HTTP_AUTH_TOKEN` | No | - | Shared bearer token required on every HTTP/SSE request. Minimum 16 characters. Unset means no authentication. |
| `PIHOLE_HTTP_AUTH_TOKEN_FILE` | No | - | Path to a file holding the bearer token, so it stays out of `ps`. Mutually exclusive with `PIHOLE_HTTP_AUTH_TOKEN`. |
| `PIHOLE_RATE_LIMIT` | No | `120` | Per-session requests-per-minute cap on the HTTP/SSE transports, under a per-address ceiling of four times that. `0` disables. |
| `PIHOLE_ALLOWED_ORIGINS` | No | `localhost,127.0.0.1,[::1]` | Comma-separated Origin/Host allowlist for HTTP/SSE transports. The literal `*` disables enforcement (unsafe). Not authentication. |
| `PIHOLE_TRUSTED_PROXIES` | No | - | Comma-separated IPs or CIDR blocks whose `X-Forwarded-For` the rate limiter believes. Unset means the header is ignored. |
| `PIHOLE_TLS_SKIP_VERIFY` | No | `false` | Disable TLS certificate verification for Pi-hole connections. Only for instances serving self-signed certificates. A trusted certificate is preferable where possible. |
| `TZ` | No | System timezone (UTC in Docker) | IANA timezone for rendered timestamps (e.g. `Australia/Adelaide`). Timezone data is embedded in the binary, so this works in the Docker image out of the box. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | No | - | OpenTelemetry collector endpoint. Setting it enables tracing; ignored in slim builds. |
Application passwords are recommended for automation: they bypass TOTP 2FA and can be revoked independently.
`PIHOLE_HTTP_AUTH_TOKEN`, `PIHOLE_HTTP_AUTH_TOKEN_FILE`, `PIHOLE_RATE_LIMIT`, `PIHOLE_ALLOWED_ORIGINS` and `PIHOLE_TRUSTED_PROXIES` only apply to the `http` and `sse` transports; stdio is a single-process, single-user channel by definition and isn't gated. See [Security](#security-http-and-sse-transports) before exposing either transport beyond loopback.
### Scoping the tool surface
By default the server offers every tool, including 34 that change Pi-hole and 27 that are
destructive. Two variables narrow that.
`PIHOLE_READ_ONLY=true` exposes only tools that cannot change anything. Write tools are absent from
`tools/list` **and** rejected if called anyway, so a client working from a cached listing cannot
reach one either.
```bash
PIHOLE_READ_ONLY=true # 49 tools instead of 82
```
`PIHOLE_TOOLSETS` selects which families of tools exist at all, which is worth doing on its own
because `tools/list` is a cost paid in every conversation:
```bash
PIHOLE_TOOLSETS=dashboard,domains,lists # blocklist curation, and nothing else
PIHOLE_TOOLSETS=all # the default, stated explicitly
```
The two intersect, and read-only always wins. `PIHOLE_READ_ONLY=true` with
`PIHOLE_TOOLSETS=dns,stats` gives the 14 tools that are in those toolsets *and* read-only.
The toolset names, what each contains and how many tools it holds are in
[docs/TOOLS.md](docs/TOOLS.md#toolsets), generated from the same table the server filters with. An
unknown name fails at startup and lists every valid one; run `pihole-mcp -check` to see it, along
with how many tools your configuration actually exposes:
```
scope: read-only=true, toolsets=dns,stats 14 of 82 tools exposed
```
A published toolset name is never removed, renamed or narrowed. See
[CONTRIBUTING.md](CONTRIBUTING.md#toolset-stability) for the full promise.
### Multiple instances
To manage more than one Pi-hole, configure numbered instances instead of `PIHOLE_URL`/`PIHOLE_PASSWORD`:
| Variable | Required | Description |
|----------|----------|-------------|
| `PIHOLE_1_URL`, `PIHOLE_2_URL`, … | Yes | Base URL of each instance (contiguous from 1) |
| `PIHOLE_1_PASSWORD`, `PIHOLE_2_PASSWORD`, … | Yes | Password for the matching instance |
| `PIHOLE_1_NAME`, `PIHOLE_2_NAME`, … | No | Friendly name (default `instance-1`, `instance-2`, …) |
```json
{
"mcpServers": {
"pihole": {
"command": "pihole-mcp",
"env": {
"PIHOLE_1_URL": "http://192.168.1.2",
"PIHOLE_1_PASSWORD": "primary-password",
"PIHOLE_1_NAME": "downstairs",
"PIHOLE_2_URL": "http://192.168.1.3",
"PIHOLE_2_PASSWORD": "secondary-password",
"PIHOLE_2_NAME": "upstairs"
}
}
}
}
```
Every tool then accepts an optional `instance` argument, and every result is labelled with the instance it came from. Omit the argument to target the first instance; pass a name to target a specific one; pass `instance=all` on a read-only tool (e.g. `pihole_padd`, `pihole_stats_summary`) to query every instance concurrently and get back a single structured aggregate (per-instance results plus a success/failure summary, so one slow or unreachable instance no longer fails the whole call). State-changing tools require a single named instance. `PIHOLE_URL` and `PIHOLE_1_URL` are mutually exclusive.
### Keeping instances in sync
When you run more than one Pi-hole, two extra tools appear for keeping them aligned:
- **`pihole_instance_diff`**: compare two instances and see exactly what differs across adlists/allowlists, allow/deny rules (exact and regex), groups, clients, local DNS A/AAAA records, and CNAME records. It is read-only and writes nothing.
- **`pihole_instance_sync`**: push a source instance's configuration onto a target. It is deliberately cautious:
- **One direction only.** You name the `source` of truth and the `target`; only the target is ever written to.
- **Dry-run first.** It returns a plan and a `confirm_token` by default; nothing changes until you re-run with `mode=apply` and that token. If the configuration drifts between planning and applying, the token no longer matches and the apply is refused.
- **Add/update by default.** Entries on the target but not the source are left alone unless you pass `prune=true`.
- **Backed up.** A teleporter backup of the target is taken before any change (disable with `snapshot=false`).
- **Safe by omission.** Host-specific and identity settings (DHCP, interface bindings, passwords, TLS certificates, sessions, 2FA) are never synced. Group *membership* associations are not synced either, because Pi-hole group IDs are local to each instance.
Example: preview what the `upstairs` Pi-hole is missing relative to `downstairs`, then apply it.
```text
pihole_instance_diff { "source": "downstairs", "target": "upstairs" }
pihole_instance_sync { "source": "downstairs", "target": "upstairs" } → returns a plan + confirm_token
pihole_instance_sync { "source": "downstairs", "target": "upstairs",
"mode": "apply", "confirm_token": "