Containerize MCP tool services
This commit is contained in:
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
@@ -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;'
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+52
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user