prowlarr-mcp-server
by Barrow1990
README.md
# prowlarr-mcp-server
A minimal [Model Context Protocol](https://modelcontextprotocol.io) server that
connects to [Prowlarr](https://prowlarr.com), packaged for Docker.
It runs as a standing network service (streamable-http transport, not stdio),
so any MCP client on your internal network can connect to
`http://<host>:<port>/mcp` — the container isn't spawned per-client, and
container lifecycle/updates can be handed off to a tool like
[Dockhand](https://dockhand.pro).
## Tools
| Tool | Description |
|---|---|
| `list_indexers` | List indexers configured in Prowlarr, optionally filtered to only enabled ones |
| `indexer_details` | Full details for one indexer by ID |
| `test_indexer` | Trigger Prowlarr to test connectivity for an already-configured indexer |
| `search_releases` | Run a manual release search across indexers (does not grab/download anything) |
| `system_status` | Prowlarr system status and health checks |
`test_indexer` is the only tool that reaches out and exercises a real indexer
connection. Everything else is read-only. Unlike Sonarr/Radarr, Prowlarr has
no library on disk, so there's no `missing_*`/`diskspace`-style tool here —
it manages indexers, not downloaded media.
## Health endpoints
Two plain HTTP endpoints, reachable without `MCP_AUTH_TOKEN` (so Docker's
`HEALTHCHECK`, Dockhand, or any other monitor can poll them without the
secret):
| Endpoint | Checks | Healthy | Unhealthy |
|---|---|---|---|
| `GET /health` | The process is up and serving HTTP. Does **not** call Prowlarr. | `200 {"status": "ok"}` | (doesn't respond) |
| `GET /ready` | `PROWLARR_URL` is reachable, `PROWLARR_API_KEY` is accepted (via Prowlarr's `/system/status`), *and* `PROWLARR_API_VERSION` is still an API version Prowlarr serves (see [API version checking](#api-version-checking)). | `200 {"status": "ok", "reachable": true, "authenticated": true, "prowlarr": {...}, "apiVersion": {...}}` | `503 {"status": "error", "reachable": ..., "authenticated": ..., "error": "..."}` |
They're split deliberately: `/health` is what the container's own
`HEALTHCHECK` uses (so a transient Prowlarr outage doesn't get the container
itself restarted in a loop), while `/ready` is for verifying config — after
changing `PROWLARR_URL`/`PROWLARR_API_KEY`, `curl http://<host>:8934/ready`
tells you plainly whether the host is reachable, the key is valid, or both.
## Authentication
Set `MCP_AUTH_TOKEN` (a random shared secret — `openssl rand -hex 32`) and
every request must carry `Authorization: Bearer <token>` or the server
returns `401`. This is checked by a small Starlette middleware in front of
the MCP app, **not** the `mcp` SDK's built-in OAuth support
(`mcp.server.auth`) — that machinery expects a full OAuth authorization
server (issuer/resource metadata, RFC 8414/8707/9068 discovery), which is
unnecessary complexity for one secret shared by trusted LAN clients.
Leave `MCP_AUTH_TOKEN` unset and the server runs with **no auth** — anything
that can reach `http://<host>:<port>/mcp` can call every tool, including
`test_indexer`. The server logs a warning on startup when it's running this
way. Either way, the trust boundary is still the network:
- **Do not** publish this port through any reverse proxy, port-forward, or
anything else reachable from outside your LAN/VLAN — the bearer token
protects against anyone *on* the network, not against the open internet.
- Bind the compose `ports:` mapping to a specific internal interface (e.g.
`192.168.1.50:8934:8934`) rather than all interfaces, if you want to be
stricter about which hosts on your network can reach it at all.
## Configuration
Environment variables (see `.env.example`):
| Variable | Required | Default | Description |
|---|---|---|---|
| `PROWLARR_URL` | yes | — | e.g. `http://192.168.1.50:9696` |
| `PROWLARR_API_KEY` | yes | — | Prowlarr > Settings > General > API Key |
| `PROWLARR_API_VERSION` | no | `v1` | Prowlarr REST API version to call (`/api/<version>/...`) |
| `MCP_HOST` | no | `0.0.0.0` | Interface the server binds to inside the container |
| `MCP_PORT` | no | `8934` | Port the server listens on |
| `MCP_AUTH_TOKEN` | no | — | Shared secret required as `Authorization: Bearer <token>`. Unset = no auth (see above) |
### API version checking
Prowlarr shares its underlying HTTP framework with Sonarr/Radarr (all three
are Servarr apps built on the same *arr common codebase), so it exposes the
same unauthenticated, unversioned `GET /api` endpoint reporting which API
version is current and which are deprecated (e.g. `{"current": "v1",
"deprecated": []}`). `GET /ready` calls it and compares it against
`PROWLARR_API_VERSION`:
- version matches `current`, or is listed under `deprecated` (still served,
just on notice) → healthy, reported under the response's `apiVersion` key.
- version isn't offered at all any more → `503`, since every tool call
would otherwise start failing with 404s. Bump `PROWLARR_API_VERSION` to
match what Prowlarr now reports.
- Prowlarr doesn't have this endpoint (very old versions) or it's
unreachable → non-fatal, `apiVersion: {"checked": false}`.
This turns a silent break on a Prowlarr upgrade into a readiness-probe
failure instead. Prowlarr's API has been on `v1` since it was introduced, so
in practice this is unlikely to trip — it's here for consistency with the
sonarr/radarr servers and as a safety net if that ever changes.
## Image
Built and pushed to `ghcr.io/barrow1990/prowlarr-mcp-server` by
[`.github/workflows/ci.yml`](.github/workflows/ci.yml) on every push to
`main` that passes tests, tagged `:latest`, `:<commit-sha>`, and
`:prowlarr-<api-version>` (e.g. `:prowlarr-v1` — the Prowlarr API version
this build targets, read out of `server.py`'s `PROWLARR_API_VERSION` default
so it can't drift from what the code actually calls). `docker-compose.yml`
pulls `:latest` by default; swap in `build: .` there instead if you'd rather
build locally from the `Dockerfile`.
The image is a three-stage build: `builder` compiles dependencies into
`--target=/deps` (all of them, including `cryptography`'s compiled `cffi`
extension, ship musllinux wheels, so this needs no compiler even on alpine);
`prep` starts fresh from `python:3.12-alpine`, drops pip/setuptools/wheel,
strips stdlib pieces this headless server never touches (`tkinter`,
`idlelib`, `lib2to3`, `ensurepip`, ...), adds the non-root `app` user, and
copies in `/deps` and `server.py`; `runtime` then does a single
`COPY --from=prep / /` onto a `scratch` base. That last step matters more
than it looks — a plain `RUN rm -rf` only *hides* files still physically
present in the base image's own layers underneath, so it doesn't shrink a
normal layered image at all; copying the already-trimmed filesystem onto
`scratch` is what actually drops those bytes from what gets pushed.
That takes the published image to roughly **~98MB**. The floor from here is
`mcp`'s own dependency graph: `mcp.server.request_state` unconditionally
imports `cryptography`'s AES-GCM/HKDF (spec-mandated integrity protection for
MCP's `requestState`, not something gated behind JWT/OAuth use), so its
~15MB native extension ships regardless. Dependencies in `requirements.txt`
are pinned to exact versions rather than `>=` ranges, so a routine
`docker build` can't silently pull in a heavier resolution than
the one that was actually tested.
## Running with Docker Compose
```bash
cp .env.example .env # fill in PROWLARR_URL / PROWLARR_API_KEY
docker compose up -d --pull always
```
The server is then reachable at `http://<docker-host>:8934/mcp` from anything
on your internal network.
## Managing with Dockhand
Point Dockhand at `ghcr.io/barrow1990/prowlarr-mcp-server` and let it track new
tags — this is the registry-pull model Dockhand's image-update tracking
(Grype/Trivy scans, tag tracking, scheduled updates) is actually built around.
The alternative, pointing Dockhand at this repo as a Git-deployed Compose
stack with `build: .`, works too, but syncing new Git commits does **not**
imply rebuilding the image — those are two separate steps for a build-from-
source stack.
**Make the GHCR package public**, or every pull will need `docker login
ghcr.io` with a PAT on each deploy host — a private package by default
requires auth even to `docker pull`, which most homelab boxes won't have
configured.
Set a restart policy of `unless-stopped` (already in `docker-compose.yml`) so
Dockhand-driven restarts and host reboots bring it back up without manual
intervention. The `HEALTHCHECK` in the `Dockerfile` (`GET /health`) drives
Docker's/Dockhand's container health status; use `GET /ready` (see above)
separately if you want to alert on Prowlarr connectivity specifically rather
than container liveness.
**Environment variables in Dockhand**: `docker-compose.yml` loads
`PROWLARR_URL`/`PROWLARR_API_KEY`/`MCP_AUTH_TOKEN` via `env_file: [.env, .env.dockhand]`
(both optional; `.env.dockhand` loads second, so it wins for any key it also
sets). This is deliberate — a Git-deployed stack's `.env` is whatever's
checked out from the repo (i.e. `.env.example`'s placeholders, since real
`.env` is gitignored and not committed), while Dockhand writes the values you
configure in its UI to `.env.dockhand` instead. If you set `PROWLARR_URL` in
Dockhand's UI and the container is still using a placeholder, check that
Dockhand is actually writing to `.env.dockhand` in the stack directory (not
some other file) and that a rebuild has run since — a synced Git file change
alone doesn't rebuild the image; see `GET /ready` to confirm what's live.
## Connecting a client
### Claude Code
```bash
claude mcp add prowlarr -s user --transport http http://<docker-host>:8934/mcp \
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
```
(Drop the `--header` flag if you're running with `MCP_AUTH_TOKEN` unset.)
### Claude Desktop
Claude Desktop's built-in config expects a locally-spawned `command`, so for
a network server like this you'll need an HTTP-to-stdio bridge such as
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
```json
{
"mcpServers": {
"prowlarr": {
"command": "npx",
"args": [
"-y", "mcp-remote", "http://<docker-host>:8934/mcp",
"--header", "Authorization: Bearer <MCP_AUTH_TOKEN>"
]
}
}
}
```
## Running without Docker
```bash
pip install -r requirements.txt
PROWLARR_URL=http://192.168.1.50:9696 PROWLARR_API_KEY=your-api-key \
MCP_AUTH_TOKEN=your-shared-secret python server.py
```
## Testing
```bash
pip install -r requirements-dev.txt
python -m pytest tests/ -v
```
- `tests/test_tools.py` — each tool's logic against a mocked Prowlarr
(`httpx.MockTransport`, no extra mocking library needed).
- `tests/test_http.py` — `/health`, `/ready`, and the bearer-auth middleware,
via `server.build_app()` (the exact app `__main__` runs) through Starlette's
`TestClient`.
- `tests/test_live_prowlarr.py` — **opt-in** contract tests against a real
Prowlarr instance, to catch drift if a Prowlarr upgrade renames/removes a
field these tools depend on (`id`, `name`, `enable`, `indexerId`, `title`,
...). Skipped by default (no Prowlarr in CI); run with:
```bash
RUN_LIVE_PROWLARR_TESTS=1 PROWLARR_URL=https://prowlarr.example.com \
PROWLARR_API_KEY=<real key> python -m pytest tests/test_live_prowlarr.py -v
```
CI (`.github/workflows/ci.yml`) runs the mocked suite on every push/PR; the
GHCR build only runs after it passes.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues