Skip to main content
Glama

MediaHub

A home media stack you run by talking to an agent. One MCP server is the only interface: request a show, see what's downloading, fix a stuck torrent, get recommendations, free disk space, all by asking Claude Code, Codex, or a chat bot that drives one. A deterministic loop keeps downloads moving in the background, and an optional alert channel (Telegram or a webhook) tells you the few things it can't fix.

Agent (Claude Code / Codex / Pi / a chat bot) ── media skill
        │  MCP over HTTP (bearer token), 127.0.0.1:8000/mcp
        ▼
mediahub container ── watch loop (every 2 min) ── alerts (optional)
        ▼
Plex · Sonarr · Radarr · Prowlarr · FlareSolverr · qBittorrent · Bazarr

Install

Point your coding agent at this repo (https://github.com/bartholomewtj/mediahub-mcp) and say "install MediaHub". It follows AGENTS.md and then INSTALL.md: checks your machine, asks you a handful of questions in one go (media folder, timezone, VPN, alerts, delete mode, limits), starts the stack, wires the apps together, connects your agents and verifies the result. Secrets (Telegram token, VPN login) you paste into a file yourself, never into the chat.

Later: "update MediaHub" (UPDATE.md: backup, new release, settings check, verify) and "uninstall MediaHub" (UNINSTALL.md: keeps your media and, unless you say otherwise, your data).

Requirements

  • Linux with Docker Engine and Docker Compose 2.24.4 or newer, or Windows 10/11 with Docker Desktop (WSL 2 backend). No macOS in v0.1.

  • A disk for media with plenty of room: requests stop when free space would fall below 50 GB (DISK_GUARD_GB).

  • An MCP client: Claude Code, Codex, or any client that speaks MCP over HTTP with a bearer header. Optionally a chat bot that runs headless Claude Code on the same machine.

  • Your own indexers (you add them in Prowlarr; MediaHub ships none and names none).

  • Optional: a Plex account, a VPN provider supported by gluetun, a Telegram bot for alerts.

What you get

  • Full mode (default): MediaHub plus its own Sonarr, Radarr, Prowlarr, FlareSolverr, qBittorrent (optionally behind a gluetun VPN), Plex and Bazarr, wired to each other by setup wire. Or core mode: MediaHub alone in front of apps you already run. An existing stack can be imported instead (the installer's REPLACE path keeps your library, history and Plex watch history, and stops but never deletes the old apps).

  • 19 MCP tools (below) and the media skill that teaches an agent to use them.

  • The watch loop: swaps dead torrents, rechecks nearly-done ones, retries requests that never grabbed, and alerts you about the rest.

  • A read-only status page, health checks, backup and verify scripts for both OSes.

Safety model

  • Local only. Everything listens on 127.0.0.1 (Plex also on your LAN, port 32400), and the MCP needs a bearer token. Remote access is your decision, never the installer's: docs/connect-remote.md.

  • Secrets stay in files in MEDIAHUB_HOME (default ~/.mediahub), outside the repo. Setup generates and harvests them and prints key names only.

  • Stranger-written text is fenced. Release names and error text are data, never instructions or parameters.

  • Deletes are gated. A confirm form in Claude Code; elsewhere a bounded mode (cleanup candidates only for deletes). Deleting anything else (delete_any) needs a one-time code sent to your alert channel unless you opt out. Every delete can be undone for 7 days.

  • Bounded actions. Disk guard, requests per hour, grabs per title.

  • VPN is optional. Without it your internet provider can see the torrent traffic; the installer says so plainly.

Docs

AGENTS.md

Entry point and hard rules for any agent

INSTALL.md, UPDATE.md, UNINSTALL.md

The playbooks agents follow

docs/connect-remote.md

Reaching MediaHub from a phone or another machine (your choice, your risk)

CHANGELOG.md

What changed in each version

Related MCP server: Arr Suite MCP Server

Design

  • MCP is the only interface. Agents act on your media through the tools the server registers; nothing else (and no direct *arr, Plex or qBittorrent API call) is part of the contract.

  • The watch loop is deterministic. Every 2 minutes, no LLM: it retries, swaps and flags stuck downloads by fixed rules, and alerts you only for what it cannot fix.

  • Untrusted text is fenced. Release names and *arr error text arrive inside an untrusted object, cleaned and truncated, and never go into alerts or tool parameters.

  • Ids are server-issued. Actions take ids the server handed out (like t:radarr:17), re-checked against live state on every call.

  • Destructive actions are bounded. Elicitation form in Claude Code; elsewhere cleanup candidates only for deletes, 5 remove+blocklists a day, and a 7-day recycle bin you can undo from.

  • Safe defaults. Everything binds to 127.0.0.1, secrets stay in files outside the repo, and guards (disk, request and grab limits, confirmation codes) are on unless you turn them off.

Using it

Ask the agent. The media skill (skill/SKILL.md; the installer links it into your agent's skills folder) tells it how. Examples:

  • "Get me season 1 of Night Shift Nine."

  • "What's downloading? Anything stuck?"

  • "Recommend something — poll me on what I've watched first."

  • "I need 100 GB back."

The tools

Tool

What it does

Gate

status(deep?)

Services, media disk free, downloads with swarm health, playing now, watch-loop actions

—

problems()

What needs attention, with the actions that apply

—

fix(action, target_id?)

refresh_all, prowlarr_test, queue_refresh, qbit_recheck, qbit_resume

—

find(query, type?)

Look up by title or tmdb:/tvdb:; on stack? in Plex? taste?

—

request(type, tmdb_id, seasons?)

Add to Radarr/Sonarr and search. TV needs explicit seasons

disk guard, 5/hour

releases(target_id) / search(target_id, query)

Filtered, scored candidates for a stuck item

—

grab(release_key)

Download one of them (replacing the stuck one)

2 per item, disk guard

library(type?)

Everything on the stack and in Plex: size, watched state, taste rating, seasons, part-way items, downloads, stalled ones; item_id per title

—

history() / taste() / taste_record(...)

Watch history, the taste ledger (+ recent rating changes), record a poll answer

taste_record never changes a rating

set_rating(tmdb_id, title, verdict, why?)

Change a rating (loved liked meh disliked skip not_mine) of a title already in the ledger

only a title the owner names; every change logged in data/rating_changes.json

cleanup_candidates(free_gb?)

What could go: watched, not in 14 days, not loved, nothing part-way

—

delete(candidate_id) / remove_blocklist(target_id)

Destructive

Claude Code: the owner confirms in a form. Elsewhere: bounded (below)

delete_any(item_id, reason, seasons?)

Delete any title or named seasons, candidate or not

a reason, not while playing, a one-time code by default (see below)

deleted()

Every delete in 30 days, each with its undo_id, whether it can still be undone, and the steps

—

undo_delete(undo_id)

Put a delete back: files out of the recycle bin, title back in Sonarr/Radarr, rescan

within 7 days, once

Trust rules (enforced in the server)

  • Stranger-written text is fenced. Release names and *arr error text come back inside untrusted, cleaned and truncated, and are never accepted as parameters.

  • Server-issued ids only. Actions take ids like q:sonarr:<hash> or t:radarr:17, re-checked against live state on every call.

  • Destructive actions. In interactive Claude Code, delete and remove_blocklist use MCP elicitation: the owner ticks a Confirm form the model can't answer. Everywhere else — a chat bot running headless Claude Code, Codex, Pi — they run in bounded mode: deletes only for cleanup candidates (watched, 14 days, not loved, nothing part-way), at most 5 remove+blocklists per 24 hours, deleted files kept 7 days in the recycle bin (deleted() explains undo), and an alert for each. A form the owner declines always stops it.

  • delete_any (anything). The way to delete a non-candidate (and any title). item_id from library(), a reason of at least 3 words (logged), refused while the title is playing or if Plex can't be read, same recycle bin and undo_delete. It has three states, shown by status() as alerts.delete_any:

    • code (the default, DELETE_ANY_REQUIRE_CODE=true): the first call sends the owner a six-digit code through the alert channel and returns code_sent; the second call includes the code (single-use, bound to the target, 10 minutes, 3 tries then a 10-minute lockout, stored only as a salted hash).

    • disabled: a code is required but there is no alert channel, so delete_any refuses up front. Bounded delete of cleanup candidates still works. Set up a channel, or choose no-code mode.

    • no_code (DELETE_ANY_REQUIRE_CODE=false, an explicit opt-in): one call, and works with no channel. The only guards against a wrong delete are the prompt rule "only a title the owner named" and the 7-day undo.

  • undo_delete. Every delete records the files it removes. Undo moves them from /media/.recyclebin back to their old paths (never outside /media, never over an existing file), re-adds the title at its old folder if it was removed (no search), re-monitors the seasons and rescans. The mediahub container therefore mounts /media read-write. Seeding torrents are not brought back.

  • set_rating. Overwrites a rating, logs old and new in data/rating_changes.json (last 200; taste() shows the latest 10), drops the old why unless a new one is given. The title text must match the ledger row for that tmdb_id. "Only a title the owner named" is enforced by the skill and the bot prompt, not the server.

  • Bounded safe actions. Requests stop below 50 GB free (DISK_GUARD_GB) and after 5 an hour; agent grabs stop after 2 per item.

  • Alerts carry no stranger text. Every channel gets fixed wording and titles from Sonarr/Radarr metadata only. See Alerts below.

Connect a client

The token is MCP_TOKEN in <MEDIAHUB_HOME>/secrets.env (default ~/.mediahub). One script per OS registers the server with a client without ever printing the token (compose/scripts/register-mcp.ps1 on Windows, register-mcp.sh on Linux; --url / -Url changes the default http://127.0.0.1:8000/mcp):

Client

Command

What it does

Claude Code

register-mcp.ps1 claude

claude mcp remove --scope user mediahub (failure ignored), then claude mcp add --transport http --scope user mediahub <url> --header "Authorization: Bearer <token>"

Codex CLI

register-mcp.ps1 codex

adds [mcp_servers.mediahub] with url and bearer_token_env_var = "MEDIAHUB_MCP_TOKEN" to ~/.codex/config.toml (or $CODEX_HOME), replacing an older entry; sets the user env var MEDIAHUB_MCP_TOKEN (Linux: a line in ~/.profile that reads it from secrets.env). Restart Codex afterwards

Anything else

register-mcp.ps1 json

writes <MEDIAHUB_HOME>/mcp-client.json ({"mcpServers":{"mediahub":{"type":"http","url":...,"headers":{...}}}}, mode 600 on Linux) and prints only its path

.\compose\scripts\register-mcp.ps1 claude

Installer helpers

python -m app.setup <command>, run in the stack as mh run --rm setup <command>:

Command

What it does

allowed-tools

The --allowedTools string: mcp__mediahub__<tool> for every tool the server registers, comma-separated, in registration order

bot-prompt --repo-dir P --shell powershell|bash

templates/bot-prompt.md rendered from the config (OWNER_NAME, DISK_GUARD_GB, the alert channel, the delete_any mode) for a headless-agent bot. P is the host folder that holds compose/.

config-diff

After an update: NEW / OPTIONAL for keys in the shipped stack.env.example / secrets.env.example that your files lack, UNKNOWN for keys they do not know. Names only, never values. Exit 0

remap-roots --app sonarr|radarr --from OLD --to NEW [--apply]

For an existing Sonarr/Radarr whose series/movies use another container path (for example /tv and you now mount /media/TV): lists what would change (count, up to 10 titles); with --apply adds the new root folder if missing and re-points the titles through the bulk editor (moveFiles: false, files are not moved), one batch per root folder, checking the resulting paths

version

Prints VERSION

Alerts

Alerts and delete_any codes go out over one optional channel, chosen by NOTIFIER in secrets.env (TELEGRAM_* and WEBHOOK_* in compose/secrets.env.example):

NOTIFIER

What happens

none

Nothing is sent. delete_any is disabled unless DELETE_ANY_REQUIRE_CODE=false.

telegram

One-way messages from your own bot (TELEGRAM_BOT_TOKEN from @BotFather, TELEGRAM_CHAT_ID), written for you to read. No markup is interpreted; the token is never logged.

webhook

A JSON POST with a bearer key (WEBHOOK_URL, WEBHOOK_KEY) to an agent relay that forwards the note to the media bot. WEBHOOK_EXTRA_JSON in stack.env (not secret) merges extra fields over the JSON body for a relay with its own wire format; a default field set to null is dropped.

Left blank, NOTIFIER is chosen from what is set: webhook, else telegram, else none.

The watch loop

Every 2 minutes, without an LLM:

  • Groups the Sonarr/Radarr queues by torrent (a season pack is one torrent).

  • A download that hasn't moved for 40 minutes (30 min grace), or whose swarm is dead (no availability, no seeds for 10 minutes), is swapped for a healthier release — a season search for packs. At most 2 swaps. Nearly-done stalls get one resume + recheck first.

  • Requests made through the MCP that never grabbed anything are searched again with backoff (1h, 6h, 24h) per movie / season / episode.

  • Releases are filtered (no CAM/TS, no archives or executables, size sane for the runtime, English) and scored by seeders × the indexer's import track record squared, from 90 days of Sonarr/Radarr history.

  • What it can't fix becomes a problem for the agent and one alert on the configured channel.

  • If qBittorrent or a queue can't be read, it judges nothing that pass.

Pause it with WATCH_ENABLED=false in stack.env (then mh up -d mediahub).

It also sends two weekly messages through the alert channel (local time, set in stack.env): WEEKLY_CLEANUP=SUN 10:00 lists cleanup candidates for the owner to pick from, and WEEKLY_PICKS=FRI 18:00 asks for 3 recommendations. With no channel nothing is sent.

New requests use the HD-720p profile (DEFAULT_PROFILE) to save space; titles already on the stack keep their profile.

Read-only page

http://localhost:8000 — services, media disk, downloads, problems, watch loop, playing now. No buttons; actions go through the agent.

Production (Docker Desktop, or Linux)

compose/docker-compose.yml (the core), project name mediahub, always run through compose\mh.ps1 (or compose/mh.sh on Linux, WSL, Git Bash), which adds --env-file <MEDIAHUB_HOME>\stack.env and picks the compose files from it:

stack.env

Files

You get

MEDIAHUB_STACK=full (default)

docker-compose.yml + stack.yml

MediaHub plus bundled Sonarr, Radarr, Prowlarr, FlareSolverr, qBittorrent, Plex, Bazarr

MEDIAHUB_STACK=core

docker-compose.yml

MediaHub only, on 127.0.0.1:8000. Set SONARR_URL, RADARR_URL, ... in secrets.env (e.g. http://host.docker.internal:8989). No other port is published, so your own apps keep theirs

MEDIAHUB_STACK=full + MEDIAHUB_VPN=gluetun

+ vpn.yml

qBittorrent runs inside a gluetun VPN container. Provider: stack.env.example; credentials: vpn.env.example (gluetun reads only that file). Sonarr and Radarr reach qBittorrent at host gluetun, port 8090

Dashboard links (/api/config) show the URLs MediaHub itself uses, so with core URLs or the VPN they may not open from a browser.

Media in MEDIA_ROOT (/media in every container). App state in named volumes. Every app shares the net container's network namespace, so services reach each other on 127.0.0.1. Ports publish on 127.0.0.1 only, except Plex (32400, LAN).

Settings

%USERPROFILE%\.mediahub\stack.env (template compose\stack.env.example). Not secret.

Secrets

%USERPROFILE%\.mediahub\secrets.env (template compose\secrets.env.example; setup secrets fills it, below). Never in the repo or the agent workspace.

Backups

%USERPROFILE%\.mediahub\backups\ — compose\scripts\backup.ps1 / backup.sh, keeps 4. Schedule it weekly (the installer offers to)

Health

compose\scripts\verify-stack.ps1 (pings + GET /api/checks); Linux: compose/scripts/verify-stack.sh

Config as code

mh run --rm setup check / wire inside the stack (first-run wiring + the steps below), (Bazarr links, no *arr ntfy, recycle bin 7 days, Prowlarr indexers in PROWLARR_DROP_INDEXERS / PROWLARR_FLARE_INDEXERS from stack.env). Same code: app/setup.py

Linux scripts

backup.sh (--no-stop, --keep N), verify-stack.sh, sync-runtime-secrets.sh next to the .ps1 files, same behaviour. plex-lan is Windows-only

Plex on the LAN

compose\scripts\plex-lan.ps1 -HostIp <host-lan-ip> (Docker Desktop has no bridged networking)

cd <repo>\compose
.\mh.ps1 ps
.\mh.ps1 up -d --build mediahub      # after changing app/
.\mh.ps1 logs -f --tail 50 mediahub
.\scripts\verify-stack.ps1                  # -AllowLowDisk while the disk is below the guard; -Fresh on a new install
                                            # (unclaimed Plex, no indexers: PENDING instead of FAIL)
.\mh.ps1 run --rm -T setup mcp-ping         # checks /mcp with no client; never prints the token

Upgrade an app: bump its tag in stack.yml, .\mh.ps1 up -d <service>. Never lower a *arr or Plex tag (their databases migrate forward only). After a fresh Bazarr volume: .\scripts\sync-runtime-secrets.ps1 (runs mh run --rm setup secrets, then recreates the dashboard container).

First run (MEDIAHUB_STACK=full)

A new install is wired by commands alone: no Python on the host, and no key or password passes through the person or agent running them (setup prints key names only). setup is a compose service in the setup profile, so up never starts it; it runs the mediahub image as PUID:PGID with the app config volumes mounted (read-only, except qBittorrent's) and MEDIAHUB_HOME at /mh-home.

  1. Create <MEDIAHUB_HOME>/stack.env from compose/stack.env.example and an empty secrets.env next to it. On Linux, that folder, both files and MEDIA_ROOT must be writable by PUID (sudo chown -R $PUID:$PGID ~/.mediahub <MEDIA_ROOT>). Optional: PLEX_CLAIM= from https://plex.tv/claim (valid 4 minutes, so do step 2 straight away; see compose/plex-claim.yml).

  2. Run the stack; first start writes each app's config and API key:

.\mh.ps1 up -d                                 # ./mh.sh up -d
.\mh.ps1 stop qbittorrent                      # ./mh.sh stop qbittorrent
.\mh.ps1 run --rm setup secrets                # ./mh.sh run --rm setup secrets
.\mh.ps1 start qbittorrent                     # ./mh.sh start qbittorrent
.\mh.ps1 up -d --force-recreate mediahub       # ./mh.sh up -d --force-recreate mediahub
.\mh.ps1 run --rm setup wire                   # ./mh.sh run --rm setup wire
.\mh.ps1 run --rm setup check                  # ./mh.sh run --rm setup check
  • setup secrets copies the Sonarr, Radarr, Prowlarr and Bazarr API keys into secrets.env (the running app's key wins), PLEX_TOKEN once Plex is claimed (a token you set yourself is kept), and generates MCP_TOKEN and the qBittorrent login (QBIT_USER=admin, a random QBIT_PASS, its PBKDF2 hash written to qBittorrent.conf) only when they are empty. qBittorrent rewrites its config on exit, so it must be stopped while its password is set; secrets refuses with a STOP line if it is running. It waits up to 180 s for the apps' keys (--wait N). Every other line of secrets.env is kept as it was. Exit 1 if something is missing.

  • setup wire only adds what is missing, and never an indexer: a root folder (/media/TV, /media/Movies) if the app has none, a qBittorrent download client in Sonarr (tv-sonarr) and Radarr (radarr) if there is none (host localhost, or gluetun with MEDIAHUB_VPN=gluetun), qBittorrent's save path /media/Downloads if it is outside /media, Prowlarr's Sonarr and Radarr apps (full sync), a FlareSolverr proxy for the flare tag, Plex libraries "Movies" and "TV Shows" if Plex has no library of that type (needs PLEX_TOKEN), plus the config-as-code steps. Anything that exists in another form is left alone. It prints FIXED/OK/NOTE/FAIL lines; run it again any time.

  • setup check is wire without writes: DRIFT lines, exit 1 on drift or failure.

  • Plex unclaimed: wire prints a NOTE. Claim it (PLEX_CLAIM, or on Linux Plex's web page on port 32400 from a browser on the same network), then setup secrets, up -d --force-recreate mediahub, setup wire.

  • Then add indexers in Prowlarr (http://localhost:9696); it pushes them to Sonarr and Radarr.

Backup / restore

.\scripts\backup.ps1            # stops the stack ~1 min, tars every volume, restarts
.\scripts\backup.ps1 -NoStop    # hot copy

Restore one volume:

./mh.ps1 stop sonarr
docker run --rm -v mediahub_sonarr-config:/dst -v "$HOME\.mediahub\backups\<stamp>:/src" alpine:3.20 `
  sh -c "rm -rf /dst/* && tar -xzf /src/mediahub_sonarr-config.tgz -C /dst"
./mh.ps1 start sonarr

Rebuild from nothing

  1. Docker (Windows: Docker Desktop with WSL 2; ~/.wslconfig with [experimental] autoMemoryReclaim=gradual keeps its memory in check).

  2. Clone this repo (https://github.com/bartholomewtj/mediahub-mcp); put your saved stack.env and secrets.env back in MEDIAHUB_HOME (or create them from the templates).

  3. mh up --no-start (creates the volumes), restore each volume from the backup (above), then mh up -d, scripts/sync-runtime-secrets (runs setup secrets, recreates MediaHub), mh run --rm setup wire, scripts/verify-stack. Windows only: scripts\plex-lan.ps1.

Development

python -m venv .venv; .venv\Scripts\pip install -r requirements.txt pytest pytest-asyncio pyyaml
.venv\Scripts\python -m pytest -q

pyyaml is dev-only: tests/test_compose.py uses it to check the compose files (skipped when it is missing).

A local run (python -m uvicorn app.main:app --port 8001) reads the same secrets file. qBittorrent only answers from inside the stack, so the loop and status swarm data need the container.

Path

What

app/mcp_server.py

MCP tools (descriptions are the agent's manual)

app/tools.py

What each tool does; trust rules

app/watch.py

The loop

app/health.py

Release filters, scoring, dead-swarm check

app/stack.py

API wrappers (*arr, Prowlarr, qBittorrent, Plex)

app/services.py

The cached status snapshot

app/taste.py

The taste ledger (/taste/taste.md: TASTE_DIR or the mediahub-taste volume)

data/ (volume)

requests.json, deletions.json (30 days), rating_changes.json

app/notify.py

Alerts and codes: telegram, webhook or none

app/checks.py

Deep health checks

app/setup.py

First-run setup and config as code (python -m app.setup secrets|wire|check, run by the setup service)

app/setup_tools.py

Installer helpers: allowed-tools, bot-prompt, config-diff, remap-roots, version

templates/

bot-prompt.md, the generic media-bot prompt (copied into the image)

VERSION, CHANGELOG.md, LICENSE

Release metadata (MIT)

skill/

The media skill

compose/

Production stack

AGENTS.md, INSTALL.md, UPDATE.md, UNINSTALL.md

The agent playbooks (tests/test_playbooks.py checks the commands, paths and keys they name)

Related MCP Connectors

Related MCP Servers