Containerize MCP tool services

This commit is contained in:
Mikei386
2026-08-20 23:54:02 +02:00
parent 3ab9628088
commit f0d552ef58
28 changed files with 858 additions and 305 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /uvx /bin/
RUN uv pip install --system --break-system-packages "arr-mcp[mcp]==1.0.1"
FROM python:3.13-slim
COPY --from=builder /usr/local /usr/local
RUN groupadd --system --gid 10001 mcp \
&& useradd --system --uid 10001 --gid 10001 --no-create-home mcp
USER 10001:10001
EXPOSE 8000
ENTRYPOINT ["arr-mcp"]
CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000", "--auth-type", "none"]
@@ -0,0 +1,8 @@
FROM nginx:1.29-alpine
COPY platform/mcp/homeassistant.conf.template /etc/nginx/templates/homeassistant.conf.template
COPY platform/mcp/ha-relay-entrypoint.sh /usr/local/bin/ha-relay-entrypoint
RUN chmod 0755 /usr/local/bin/ha-relay-entrypoint \
&& mkdir -p /tmp/client_temp /tmp/proxy_temp \
&& chown -R nginx:nginx /tmp/client_temp /tmp/proxy_temp
EXPOSE 8000
ENTRYPOINT ["/usr/local/bin/ha-relay-entrypoint"]
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.13-slim
ARG MCP_PROXY_VERSION=0.12.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp>=1.17,<2" \
&& useradd --system --uid 10001 --create-home --home-dir /app mcp
RUN touch /app/unraid_mcp.py && chown 10001:10001 /app/unraid_mcp.py
USER 10001:10001
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
CMD ["python", "/app/unraid_mcp.py"]
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.13-slim
ARG MCP_PROXY_VERSION=0.12.0
RUN pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp>=1.17,<2"
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
COPY web-search/web_search_mcp.py /app/web_search_mcp.py
RUN chown -R 10001:10001 /app
USER 10001:10001
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
CMD ["python", "/app/web_search_mcp.py"]
+61 -30
View File
@@ -1,43 +1,74 @@
# MCP-Architektur
# Zentrale MCP-Werkzeugebene
Die produktive MCP-Konfiguration ist absichtlich nicht Bestandteil des Git-
Repositories, weil sie lokale Pfade und Zugangsdaten referenziert. Das Beispiel
zeigt nur die Struktur.
MCP-Werkzeuge sind **keine llama.cpp-Startparameter**. Sie laufen als kleine,
voneinander getrennte Container und werden von OpenWebUI, Hermes oder einem
anderen MCP-Client gezielt ausgewählt. Das hält Tool-Schemas aus normalen
Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
200.000 Tokens und macht Werkzeuge unabhängig vom geladenen Modellprofil.
## Empfohlene Server
## Container
- `web`: Websuche über lokales TinySearch/SearXNG
- `homeassistant`: Administration mit eigenem, minimal berechtigtem Token
- `arr`: Sonarr/Radarr über spezialisierte Aktionen
- `unraid-readonly`: Diagnose ohne Schreiboperationen
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|---|---|---|---|
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | kompakte Websuche und Quellenvergleich | an |
| `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` |
| `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` |
| `mcp-unraid-official` | `http://mike-ai-mcp-unraid-official:8000/mcp` | offizieller, read-only begrenzter Unraid-Zugang | Profil `unraid` |
| `mcp-unraid-ssh` | `http://mike-ai-mcp-unraid-ssh:8000/mcp` | erweiterte Diagnose über einen erzwungenen SSH-Befehl | optional (`extended`) |
## Getrennte Konfigurationen
TinySearch und SearXNG sind interne Abhängigkeiten des Web-MCPs und werden
nicht direkt als allgemeine Werkzeuge angeboten.
Statt alle Werkzeuge ständig zu laden, werden mehrere Dateien empfohlen:
## Sicherheitsmodell
```text
/etc/mike-ai/mcp-standard.json
/etc/mike-ai/mcp-homeassistant.json
/etc/mike-ai/mcp-arr.json
/etc/mike-ai/mcp-unraid-readonly.json
/etc/mike-ai/mcp-unraid-write.json
- Kein MCP-Port wird auf eine Host-Adresse veröffentlicht.
- Nur Clients im privaten Docker-Netz `mike-ai-tools` erreichen die Endpunkte.
- Secrets bleiben in Dateien unter `/etc/mike-ai` und werden read-only
eingehängt. Sie gehören weder in Git noch in OpenWebUI-Tooldefinitionen.
- Jeder Container ist read-only, verliert Linux-Capabilities und hat
`no-new-privileges`.
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
- Ein allgemeiner Host-Shell-MCP wird bewusst nicht angeboten.
## Start
```bash
sudo platform/mcp/install-tools.sh
```
Das jeweilige Profil verweist nur auf die benötigte Datei. Dadurch werden die
Tool-Schemas kleiner, das Kontextfenster bleibt frei und kleine Modelle müssen
weniger Werkzeuge unterscheiden.
Der Grundstart enthält nur Websuche. Bereits konfigurierte Fachbereiche werden
explizit ergänzt:
Credentials werden von schmalen Wrapper-Programmen wie `run-arr-mcp` oder
`runraid` aus geschützten Environment-Dateien geladen. Das JSON selbst enthält
weder Werte noch Pfade zu einzelnen Tokens.
Das Skript erkennt vorhandene Secret-Dateien und aktiviert dadurch automatisch
`homeassistant`, `arr` und `unraid`. Ohne Fach-Secrets startet nur der sichere
Webbereich.
## Schreibzugriff
Für den derzeit migrierten Container kann der Name `Open-WebUI` lauten. Der
Netzwerkbefehl ist idempotent zu behandeln.
Schreibende Server gehören nicht in `mcp-standard.json`. Sie benötigen eine
Vorschau und ein an die exakte Änderung gebundenes Approval Ticket.
Die lokale Installation benötigt die vorhandenen Secret-Dateien:
## Shell
```text
/etc/mike-ai/homeassistant-admin-mcp.env
/etc/mike-ai/arr-mcp.env
/etc/mike-ai/runraid/.env
```
Ein allgemeiner Shell-MCP ist nicht Teil der Zielplattform. Insbesondere
`python3`, `ssh`, `scp`, `curl` und `systemctl` dürfen nicht gemeinsam als
scheinbar harmlose Allowlist angeboten werden.
Die erweiterte Unraid-Diagnose benötigt zusätzlich die Konfigurationsdatei,
den eingeschränkten Schlüssel und die bekannte Hostsignatur. Sie wird nur mit
`--profile extended` gestartet.
TinySearch speichert sein lokales Embedding-Modell in einem Docker-Volume.
Nach einer Erstinstallation wird das Modell einmalig im Container mit
`tinysearch setup` geladen. Das Volume bleibt bei Containerupdates erhalten.
## Client-Auswahl
Werkzeuge werden nicht pauschal an jedes Modell gehängt. Für Home-Assistant-
Fragen wird HA ausgewählt, für Medien ARR, für Recherche Web und für die NAS
Unraid. Mehrere Werkzeuge werden nur aktiviert, wenn die Aufgabe tatsächlich
mehrere Bereiche verbindet.
Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und
einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals
eine read-only Policy aufheben.
+147
View File
@@ -0,0 +1,147 @@
name: mike-ai-tools
x-tool-common: &tool-common
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
networks: [tools]
logging:
options:
max-size: 10m
max-file: "3"
services:
mcp-web:
<<: *tool-common
build:
context: ..
dockerfile: mcp/Dockerfile.web
image: mike-ai/mcp-web:local
container_name: mike-ai-mcp-web
environment:
TINYSEARCH_MCP_URL: http://tinysearch:8000/mcp
SEARXNG_URL: http://searxng:8080
WEB_SEARCH_BUDGET_MAX_RELATED: "6"
depends_on:
tinysearch:
condition: service_started
searxng:
<<: *tool-common
image: searxng/searxng@sha256:e45d5894bfaa0bf8773b9f283795ae57f1c15ddb29c8cecb70b3665b0ce9ec60
container_name: mike-ai-tools-searxng
volumes:
- ${SEARXNG_SETTINGS_FILE:-../web-search/searxng-settings.example.yml}:/etc/searxng/settings.yml:ro
networks: [tools, egress]
tinysearch:
<<: *tool-common
image: marcellm01/tinysearch@sha256:5a03d5a1f1b0fabe48f2a26e05db4a84bcb611106a57ec51e42db4549976aa9c
container_name: mike-ai-tools-tinysearch
shm_size: 1gb
volumes:
- tinysearch-models:/data/models
- ../web-search/tinysearch_config.json:/config/tinysearch_config.json:ro
environment:
MCP_TRANSPORT: streamable-http
MCP_HOST: 0.0.0.0
MCP_PORT: "8000"
TINYSEARCH_CONFIG_PATH: /config/tinysearch_config.json
TINYSEARCH_SEARCH_BACKEND: searxng
SEARXNG_URL: http://searxng:8080/search
depends_on: [searxng]
cap_add: [SETUID, SETGID, CHOWN]
networks: [tools, egress]
# The image's built-in `tinysearch doctor` also requires a writable
# configuration directory, although normal server operation does not.
# Check the service socket instead so read-only hardening remains intact.
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1', 8000), 2); s.close()"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
mcp-homeassistant:
<<: *tool-common
build:
context: ../..
dockerfile: platform/mcp/Dockerfile.homeassistant-relay
image: mike-ai/mcp-homeassistant-relay:local
container_name: mike-ai-mcp-homeassistant
profiles: [homeassistant]
volumes:
- ${HA_ENV_FILE:-/etc/mike-ai/homeassistant-admin-mcp.env}:/run/secrets/homeassistant.env:ro
cap_add: [CHOWN, SETUID, SETGID]
networks: [tools, egress]
mcp-arr:
<<: *tool-common
build:
context: .
dockerfile: Dockerfile.arr
image: mike-ai/mcp-arr:1.0.1-patched
container_name: mike-ai-mcp-arr
profiles: [arr]
env_file:
- ${ARR_ENV_FILE:-/etc/mike-ai/arr-mcp.env}
volumes:
# The local fork adds bounded read-only Sonarr pseudo-actions. Keep the
# patch explicit until upstream publishes a self-contained 2.x image.
- ${ARR_SONARR_PATCH:-./patches/mcp_sonarr.py}:/usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py:ro
networks: [tools, egress]
mcp-unraid-official:
<<: *tool-common
image: debian:13-slim
container_name: mike-ai-mcp-unraid-official
profiles: [unraid]
env_file:
- ${RUNRAID_ENV_FILE:-/etc/mike-ai/runraid/.env}
environment:
UNRAID_RMCP_HOST: 0.0.0.0
UNRAID_RMCP_PORT: "8000"
UNRAID_RMCP_DISABLE_HTTP_AUTH: "true"
UNRAID_NOAUTH: "true"
UNRAID_RMCP_ALLOWED_HOSTS: "mike-ai-mcp-unraid-official:8000,mike-ai-mcp-unraid-official,localhost:8000,127.0.0.1:8000"
volumes:
- ${RUNRAID_BINARY:-/usr/local/bin/runraid}:/usr/local/bin/unraid:ro
entrypoint: ["/usr/local/bin/unraid"]
command: ["serve"]
networks: [tools, egress]
mcp-unraid-ssh:
<<: *tool-common
profiles: [extended]
build:
context: .
dockerfile: Dockerfile.unraid-ssh
image: mike-ai/mcp-unraid-ssh:local
container_name: mike-ai-mcp-unraid-ssh
environment:
UNRAID_MCP_CONFIG: /run/config/unraid-mcp.json
volumes:
- ${UNRAID_MCP_SOURCE:-/opt/mike-ai/unraid-agent/unraid_mcp.py}:/app/unraid_mcp.py:ro
- ${UNRAID_MCP_CONFIG:-/etc/mike-ai/unraid-mcp.json}:/run/config/unraid-mcp.json:ro
- ${UNRAID_SSH_KEY:-/etc/mike-ai/keys/unraid_root}:/etc/mike-ai/keys/unraid_root:ro
- ${UNRAID_KNOWN_HOSTS:-/etc/mike-ai/ssh/known_hosts_unraid_ai}:/etc/mike-ai/ssh/known_hosts_unraid_ai:ro
- unraid-audit:/var/log/mike-ai
networks: [tools, egress]
networks:
tools:
name: mike-ai-tools
internal: true
ipam:
config: [{subnet: 172.30.40.0/24}]
egress:
name: mike-ai-tools-egress
ipam:
config: [{subnet: 172.30.50.0/24}]
volumes:
tinysearch-models:
unraid-audit:
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
config=/run/secrets/homeassistant.env
if [ ! -r "$config" ]; then
echo "Home Assistant secret file is missing" >&2
exit 1
fi
set -a
. "$config"
set +a
: "${HASS_URL:?HASS_URL is required}"
: "${HASS_TOKEN:?HASS_TOKEN is required}"
upstream=${HASS_URL%/}
escaped_token=$(printf '%s' "$HASS_TOKEN" | sed 's/[&/]/\\&/g')
escaped_upstream=$(printf '%s' "$upstream" | sed 's/[&/]/\\&/g')
sed -e "s/__HASS_TOKEN__/$escaped_token/g" \
-e "s/__HASS_UPSTREAM__/$escaped_upstream/g" \
/etc/nginx/templates/homeassistant.conf.template \
> /tmp/nginx.conf
unset HASS_TOKEN
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
+30
View File
@@ -0,0 +1,30 @@
worker_processes 1;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 128; }
http {
access_log /dev/stdout;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
server {
listen 8000;
location /mcp {
proxy_pass __HASS_UPSTREAM__/api/hass_mcp;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_name $proxy_host;
proxy_set_header Authorization "Bearer __HASS_TOKEN__";
proxy_set_header Host $proxy_host;
proxy_set_header Connection "";
}
}
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -Eeuo pipefail
[[ $EUID -eq 0 ]] || { echo "Bitte als root ausführen." >&2; exit 1; }
MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE=(docker compose -f "$MCP_DIR/compose.yaml")
export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/searxng-settings.yml}"
[[ -s $SEARXNG_SETTINGS_FILE ]] || {
echo "SearXNG-Konfiguration fehlt: $SEARXNG_SETTINGS_FILE" >&2
exit 1
}
profiles=()
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
profiles+=(--profile homeassistant)
else
echo "Home Assistant bleibt aus: Secret-Datei fehlt."
fi
if [[ -s /etc/mike-ai/arr-mcp.env ]]; then
profiles+=(--profile arr)
else
echo "ARR bleibt aus: Secret-Datei fehlt."
fi
if [[ -s /etc/mike-ai/runraid/.env && -x /usr/local/bin/runraid ]]; then
profiles+=(--profile unraid)
else
echo "Unraid bleibt aus: runraid 0.4.2 oder Secret-Datei fehlt."
fi
# TinySearch keeps the embedding bundle outside the container. Download it
# once on a fresh host; subsequent rebuilds reuse the named volume.
docker volume create mike-ai-tools_tinysearch-models >/dev/null
tiny_image="marcellm01/tinysearch@sha256:5a03d5a1f1b0fabe48f2a26e05db4a84bcb611106a57ec51e42db4549976aa9c"
if ! docker run --rm --entrypoint test \
-v mike-ai-tools_tinysearch-models:/data/models "$tiny_image" \
-f /data/models/all-minilm-l6-v2-onnx/model.onnx; then
echo "TinySearch-Modell wird einmalig geladen."
docker run --rm -v mike-ai-tools_tinysearch-models:/data/models \
"$tiny_image" tinysearch setup
fi
"${COMPOSE[@]}" "${profiles[@]}" up -d --build
for webui in mike-ai-open-webui Open-WebUI; do
if docker container inspect "$webui" >/dev/null 2>&1; then
docker network connect mike-ai-tools "$webui" 2>/dev/null || true
fi
done
"${COMPOSE[@]}" "${profiles[@]}" ps
-23
View File
@@ -1,23 +0,0 @@
{
"mcpServers": {
"web": {
"command": "/usr/bin/python3",
"args": ["/opt/mike-ai/web-search/web_search_mcp.py"]
},
"homeassistant": {
"command": "/usr/local/bin/homeassistant-native-mcp",
"args": [],
"timeout_ms": 60000
},
"arr": {
"command": "/usr/local/bin/run-arr-mcp",
"args": [],
"timeout_ms": 30000
},
"unraid-readonly": {
"command": "/usr/local/bin/runraid",
"args": ["mcp"],
"timeout_ms": 60000
}
}
}
+329
View File
@@ -0,0 +1,329 @@
"""Sonarr condensed action-routed MCP tool.
CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface.
"""
import os
import json
import re
from typing import Any
from agent_utilities.mcp_utilities import dispatch, run_blocking
from fastmcp import FastMCP
from pydantic import Field
from arr_mcp.auth import get_sonarr_client
READ_ONLY_ACTIONS = frozenset(
{
"get_system_status", "get_health", "get_diskspace", "get_ping",
"get_series", "get_series_id", "get_series_lookup", "lookup_series",
"get_episode", "get_episode_id", "get_episodefile", "get_episodefile_id",
"get_calendar", "get_calendar_id", "get_history", "get_history_series",
"get_history_since", "get_queue", "get_queue_details", "get_queue_status",
"get_wanted_missing", "get_wanted_missing_id", "get_wanted_cutoff",
"get_wanted_cutoff_id", "get_qualityprofile", "get_qualityprofile_id",
"get_languageprofile", "get_languageprofile_id", "get_tag", "get_tag_id",
"get_tag_detail", "get_tag_detail_id", "get_command", "get_command_id",
"get_release",
}
)
PSEUDO_ACTIONS = frozenset({"find_series", "get_season_summary", "search_releases"})
WRITE_ACTIONS = frozenset({
"post_command",
"post_release",
"put_episode_id",
"put_episode_monitor",
"put_series_id",
"put_series",
"put_wanted",
"post_wanted",
})
MAX_COLLECTION_ITEMS = 50
def _plain(value: Any) -> Any:
if hasattr(value, "model_dump") and callable(value.model_dump):
return value.model_dump()
if hasattr(value, "dict") and callable(value.dict):
return value.dict()
if isinstance(value, list):
return [_plain(item) for item in value]
if isinstance(value, dict):
return {str(key): _plain(item) for key, item in value.items()}
return value
def _unwrap(value: Any) -> Any:
value = _plain(value)
if isinstance(value, dict) and set(value) == {"result"}:
return value["result"]
return value
def _pick(item: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
return {field: item[field] for field in fields if item.get(field) is not None}
def _compact_series(item: dict[str, Any], include_seasons: bool = False) -> dict[str, Any]:
result = _pick(
item,
("id", "title", "sortTitle", "year", "status", "monitored", "path", "tvdbId"),
)
statistics = item.get("statistics") or {}
if isinstance(statistics, dict):
result["statistics"] = _pick(
statistics,
("seasonCount", "episodeFileCount", "episodeCount", "totalEpisodeCount", "sizeOnDisk", "percentOfEpisodes"),
)
if include_seasons:
result["seasons"] = [
{
**_pick(season, ("seasonNumber", "monitored")),
"statistics": _pick(
season.get("statistics") or {},
("episodeFileCount", "episodeCount", "totalEpisodeCount", "sizeOnDisk", "percentOfEpisodes"),
),
}
for season in item.get("seasons", [])
if isinstance(season, dict)
]
return result
def _compact_episode(item: dict[str, Any]) -> dict[str, Any]:
return _pick(
item,
("id", "seriesId", "seasonNumber", "episodeNumber", "title", "airDate", "airDateUtc", "monitored", "hasFile", "episodeFileId"),
)
def _compact_file(item: dict[str, Any]) -> dict[str, Any]:
quality = item.get("quality") or {}
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
result = _pick(
item,
("id", "seriesId", "seasonNumber", "relativePath", "path", "size", "dateAdded", "releaseGroup"),
)
if quality_name:
result["quality"] = quality_name
return result
def _compact_release(item: dict[str, Any]) -> dict[str, Any]:
quality = item.get("quality") or {}
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
result = _pick(
item,
(
"guid", "title", "indexer", "indexerId", "size", "age", "ageHours",
"seeders", "leechers", "protocol", "downloadAllowed", "releaseWeight",
),
)
if quality_name:
result["quality"] = quality_name
rejections = item.get("rejections")
if isinstance(rejections, list) and rejections:
result["rejections"] = [str(reason)[:180] for reason in rejections[:5]]
return result
def _bounded(items: list[Any], compact) -> dict[str, Any]:
total = len(items)
return {
"total": total,
"returned": min(total, MAX_COLLECTION_ITEMS),
"truncated": total > MAX_COLLECTION_ITEMS,
"items": [compact(item) for item in items[:MAX_COLLECTION_ITEMS] if isinstance(item, dict)],
"next_step": (
"Use find_series or narrower Sonarr parameters; do not repeat the same broad request."
if total > MAX_COLLECTION_ITEMS else None
),
}
def _compact_result(action: str, value: Any) -> Any:
value = _unwrap(value)
if isinstance(value, list):
if action in {"get_series", "get_series_lookup", "lookup_series"}:
return _bounded(value, _compact_series)
if action in {"get_episode", "get_calendar", "get_wanted_missing", "get_wanted_cutoff"}:
return _bounded(value, _compact_episode)
if action == "get_episodefile":
return _bounded(value, _compact_file)
if action == "get_release":
return _bounded(value, _compact_release)
return _bounded(value, lambda item: item)
if isinstance(value, dict) and action in {"get_series_id"}:
return _compact_series(value, include_seasons=True)
if isinstance(value, dict) and action in {"get_episode_id"}:
return _compact_episode(value)
if isinstance(value, dict) and action in {"get_episodefile_id"}:
return _compact_file(value)
return value
async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
query = str(kwargs.get("query", "")).strip()
if len(query) < 2:
raise ValueError("find_series requires params_json with a query of at least 2 characters")
limit = max(1, min(int(kwargs.get("limit", 8)), 15))
raw = _unwrap(await run_blocking(dispatch, client, "get_series", {}, service="arr-sonarr"))
words = [word for word in re.findall(r"[a-z0-9]+", query.casefold()) if len(word) > 1]
matches = []
for item in raw if isinstance(raw, list) else []:
haystack = " ".join(
str(item.get(field, "")) for field in ("title", "sortTitle", "originalTitle", "alternateTitles")
).casefold()
if all(word in haystack for word in words):
matches.append(_compact_series(item, include_seasons=True))
return {
"query": query,
"matches": matches[:limit],
"match_count": len(matches),
"truncated": len(matches) > limit,
"task_complete": True,
"instruction": "Use the returned series id for details. Do not call get_series for discovery.",
}
async def _season_summary(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
series_id = int(kwargs["series_id"])
season_number = int(kwargs["season_number"])
series = _unwrap(
await run_blocking(dispatch, client, "get_series_id", {"id": series_id}, service="arr-sonarr")
)
episodes = _unwrap(
await run_blocking(
dispatch,
client,
"get_episode",
{"seriesId": series_id, "seasonNumber": season_number},
service="arr-sonarr",
)
)
files = _unwrap(
await run_blocking(
dispatch,
client,
"get_episodefile",
{"seriesId": series_id},
service="arr-sonarr",
)
)
selected_episodes = [
_compact_episode(item) for item in episodes
if isinstance(item, dict) and item.get("seasonNumber") == season_number
] if isinstance(episodes, list) else []
selected_files = [
_compact_file(item) for item in files
if isinstance(item, dict) and item.get("seasonNumber") == season_number
] if isinstance(files, list) else []
groups = sorted({str(item.get("releaseGroup")) for item in selected_files if item.get("releaseGroup")})
return {
"series": _compact_series(series) if isinstance(series, dict) else {"id": series_id},
"season_number": season_number,
"episode_count": len(selected_episodes),
"file_count": len(selected_files),
"release_groups": groups,
"episodes": selected_episodes[:30],
"files": selected_files[:30],
"task_complete": True,
"instruction": "This is the complete compact season answer. Do not repeat broad series or episode queries.",
}
async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
series_id = kwargs.get("series_id")
episode_id = kwargs.get("episode_id")
season_number = kwargs.get("season_number")
release_group = str(kwargs.get("release_group", "")).strip()
if series_id is None and episode_id is None:
raise ValueError("search_releases requires series_id or episode_id")
query: dict[str, Any] = {}
if series_id is not None:
query["seriesId"] = int(series_id)
if episode_id is not None:
query["episodeId"] = int(episode_id)
if season_number is not None:
query["seasonNumber"] = int(season_number)
raw = await run_blocking(dispatch, client, "get_release", query, service="arr-sonarr")
raw = _unwrap(raw)
if release_group and isinstance(raw, list):
needle = release_group.casefold()
raw = [
item for item in raw
if isinstance(item, dict)
and needle in (
str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))
).casefold()
]
compact = _compact_result("get_release", raw)
return {
"task_complete": True,
"search_scope": {
"series_id": series_id,
"episode_id": episode_id,
"season_number": season_number,
"release_group_filter": release_group or None,
},
"monitoring_changed": False,
"download_started": False,
"results": compact,
"instruction": "These are Sonarr indexer results. Do not use web search to replace them. Never download unless the user separately approves a write action.",
}
def register_sonarr_tools(mcp: FastMCP) -> None:
@mcp.tool(tags={"sonarr"})
async def sonarr_action(
action: str = Field(
description="Read-only Sonarr action. Use find_series {query} for titles, get_season_summary {series_id, season_number} for holdings, and search_releases {series_id, season_number, optional release_group} to query configured Sonarr indexers without downloading or changing monitoring. Avoid broad get_series/get_episode calls."
),
params_json: str = Field(
default="{}",
description="JSON string of parameters to pass to the action.",
),
) -> Any:
"""Query Sonarr through a server-side allowlist (read-only by default; write actions when ARR_MCP_WRITE=1)."""
if action in {"list_actions", "help", "actions"}:
return {
"service": "sonarr",
"access_mode": "write" if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else "read-only",
"actions": sorted(READ_ONLY_ACTIONS),
"write_actions": sorted(WRITE_ACTIONS) if os.environ.get("ARR_MCP_WRITE", "").strip().lower() in ("1", "true", "yes", "on") else [],
"preferred_compact_actions": sorted(PSEUDO_ACTIONS),
}
allow_write = os.environ.get("ARR_MCP_WRITE", "").strip().lower() in (
"1", "true", "yes", "on"
)
if action in READ_ONLY_ACTIONS | PSEUDO_ACTIONS:
pass
elif allow_write and action in WRITE_ACTIONS:
pass
else:
if allow_write:
raise PermissionError(
f"Sonarr MCP write mode is enabled, but action '{action}' "
"is not in the allowed write set. Allowed: "
f"{sorted(WRITE_ACTIONS)}"
)
raise PermissionError(
f"Sonarr action '{action}' is blocked by the server-side "
"read-only policy. Set ARR_MCP_WRITE=1 to enable write mode."
)
client = get_sonarr_client()
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None}
if action == "find_series":
return await _find_series(client, kwargs)
if action == "get_season_summary":
return await _season_summary(client, kwargs)
if action == "search_releases":
return await _search_releases(client, kwargs)
result = await run_blocking(
dispatch, client, action, kwargs, service="arr-sonarr"
)
return _compact_result(action, result)