initial commit
This commit is contained in:
22
config/contextforge/contextforge.yaml
Normal file
22
config/contextforge/contextforge.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
mcp_servers:
|
||||||
|
filesystem:
|
||||||
|
transport: sse
|
||||||
|
url: http://mcp-filesystem:8010/sse
|
||||||
|
gitea:
|
||||||
|
transport: sse
|
||||||
|
url: http://mcp-gitea:8000/sse
|
||||||
|
k8s:
|
||||||
|
transport: sse
|
||||||
|
url: http://mcp-k8s:8012/sse
|
||||||
|
media:
|
||||||
|
transport: sse
|
||||||
|
url: http://mcp-media:8013/sse
|
||||||
|
|
||||||
|
routing:
|
||||||
|
default_servers:
|
||||||
|
- filesystem
|
||||||
|
- gitea
|
||||||
|
- k8s
|
||||||
|
- media
|
||||||
7
config/kubeconfig.example
Normal file
7
config/kubeconfig.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
clusters: []
|
||||||
|
contexts: []
|
||||||
|
current-context: ""
|
||||||
|
kind: Config
|
||||||
|
preferences: {}
|
||||||
|
users: []
|
||||||
12
config/litellm/config.yaml
Normal file
12
config/litellm/config.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
model_list:
|
||||||
|
- model_name: ollama-default
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/${OLLAMA_DEFAULT_MODEL}
|
||||||
|
api_base: http://ollama:11434
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
set_verbose: false
|
||||||
|
drop_params: true
|
||||||
|
|
||||||
|
general_settings:
|
||||||
|
master_key: ${LITELLM_MASTER_KEY}
|
||||||
20
config/mcpo/mcpo.json
Normal file
20
config/mcpo/mcpo.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"filesystem": {
|
||||||
|
"transport": "sse",
|
||||||
|
"url": "http://mcp-filesystem:8010/sse"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"transport": "sse",
|
||||||
|
"url": "http://mcp-gitea:8000/sse"
|
||||||
|
},
|
||||||
|
"k8s": {
|
||||||
|
"transport": "sse",
|
||||||
|
"url": "http://mcp-k8s:8012/sse"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"transport": "sse",
|
||||||
|
"url": "http://mcp-media:8013/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
docker-compose.mcp.yaml
Normal file
66
docker-compose.mcp.yaml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
services:
|
||||||
|
mcp-filesystem:
|
||||||
|
build:
|
||||||
|
context: ./services/mcp-filesystem
|
||||||
|
container_name: mcp-filesystem
|
||||||
|
environment:
|
||||||
|
MCP_FILESYSTEM_ROOTS: ${MCP_FILESYSTEM_ROOTS:-/workspace,/data}
|
||||||
|
volumes:
|
||||||
|
- ./:/workspace
|
||||||
|
- ./cptr-data:/data
|
||||||
|
ports:
|
||||||
|
- "8010:8010"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
mcp-gitea:
|
||||||
|
image: docker.gitea.com/gitea-mcp-server:latest
|
||||||
|
container_name: mcp-gitea
|
||||||
|
environment:
|
||||||
|
GITEA_HOST: ${GITEA_HOST:-https://gitea.example.com}
|
||||||
|
GITEA_ACCESS_TOKEN: ${GITEA_ACCESS_TOKEN:-replace_me}
|
||||||
|
ports:
|
||||||
|
- "8011:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
mcp-k8s:
|
||||||
|
build:
|
||||||
|
context: ./services/mcp-k8s
|
||||||
|
container_name: mcp-k8s
|
||||||
|
environment:
|
||||||
|
KUBECONFIG: /config/kubeconfig
|
||||||
|
K8S_DEFAULT_NAMESPACE: ${K8S_DEFAULT_NAMESPACE:-default}
|
||||||
|
volumes:
|
||||||
|
- ${KUBECONFIG_PATH:-./config/kubeconfig.example}:/config/kubeconfig:ro
|
||||||
|
ports:
|
||||||
|
- "8012:8012"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
mcp-media:
|
||||||
|
build:
|
||||||
|
context: ./services/mcp-media
|
||||||
|
container_name: mcp-media
|
||||||
|
environment:
|
||||||
|
RADARR_URL: ${RADARR_URL:-http://radarr:7878}
|
||||||
|
RADARR_API_KEY: ${RADARR_API_KEY:-replace_me}
|
||||||
|
SONARR_URL: ${SONARR_URL:-http://sonarr:8989}
|
||||||
|
SONARR_API_KEY: ${SONARR_API_KEY:-replace_me}
|
||||||
|
QBIT_URL: ${QBIT_URL:-http://qbittorrent:8080}
|
||||||
|
QBIT_USERNAME: ${QBIT_USERNAME:-admin}
|
||||||
|
QBIT_PASSWORD: ${QBIT_PASSWORD:-adminadmin}
|
||||||
|
PLEX_URL: ${PLEX_URL:-http://plex:32400}
|
||||||
|
PLEX_TOKEN: ${PLEX_TOKEN:-replace_me}
|
||||||
|
ports:
|
||||||
|
- "8013:8013"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
llm-net:
|
||||||
|
name: llm-net
|
||||||
127
docker-compose.yaml
Normal file
127
docker-compose.yaml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
name: llm-stack
|
||||||
|
|
||||||
|
services:
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
container_name: ollama
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
volumes:
|
||||||
|
- ollama-data:/root/.ollama
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
open-webui-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: open-webui-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${OPENWEBUI_DB_NAME:-openwebui}
|
||||||
|
POSTGRES_USER: ${OPENWEBUI_DB_USER:-openwebui}
|
||||||
|
POSTGRES_PASSWORD: ${OPENWEBUI_DB_PASSWORD:-openwebui_change_me}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${OPENWEBUI_DB_USER:-openwebui} -d ${OPENWEBUI_DB_NAME:-openwebui}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
volumes:
|
||||||
|
- openwebui-db-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-stable
|
||||||
|
container_name: litellm
|
||||||
|
command: ["--config", "/app/config.yaml", "--host", "0.0.0.0", "--port", "4000"]
|
||||||
|
environment:
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-lite-llm-change-me}
|
||||||
|
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:-salt-lite-llm-change-me}
|
||||||
|
volumes:
|
||||||
|
- ./config/litellm/config.yaml:/app/config.yaml:ro
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
mcpo:
|
||||||
|
image: python:3.12-slim
|
||||||
|
container_name: mcpo
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -lc
|
||||||
|
- |
|
||||||
|
pip install --no-cache-dir mcpo &&
|
||||||
|
mcpo --config /config/mcpo.json --host 0.0.0.0 --port 8000
|
||||||
|
volumes:
|
||||||
|
- ./config/mcpo/mcpo.json:/config/mcpo.json:ro
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
contextforge:
|
||||||
|
image: ghcr.io/contextforge/contextforge:latest
|
||||||
|
container_name: contextforge
|
||||||
|
profiles: ["tools"]
|
||||||
|
environment:
|
||||||
|
CONTEXTFORGE_CONFIG: /config/contextforge.yaml
|
||||||
|
volumes:
|
||||||
|
- ./config/contextforge/contextforge.yaml:/config/contextforge.yaml:ro
|
||||||
|
ports:
|
||||||
|
- "8787:8787"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
container_name: open-webui
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${OPENWEBUI_DB_USER:-openwebui}:${OPENWEBUI_DB_PASSWORD:-openwebui_change_me}@open-webui-db:5432/${OPENWEBUI_DB_NAME:-openwebui}
|
||||||
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
|
OPENAI_API_BASE_URL: http://litellm:4000/v1
|
||||||
|
ENABLE_OLLAMA_API: "true"
|
||||||
|
ENABLE_OPENAI_API: "true"
|
||||||
|
volumes:
|
||||||
|
- open-webui-data:/app/backend/data
|
||||||
|
ports:
|
||||||
|
- "11435:8080"
|
||||||
|
depends_on:
|
||||||
|
open-webui-db:
|
||||||
|
condition: service_healthy
|
||||||
|
ollama:
|
||||||
|
condition: service_started
|
||||||
|
litellm:
|
||||||
|
condition: service_started
|
||||||
|
mcpo:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
open-webui-computer:
|
||||||
|
image: ghcr.io/open-webui/computer:latest
|
||||||
|
container_name: open-webui-computer
|
||||||
|
profiles: ["tools"]
|
||||||
|
volumes:
|
||||||
|
- ./cptr-data:/data
|
||||||
|
- ./cptr-workspace:/workspace
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ollama-data:
|
||||||
|
open-webui-data:
|
||||||
|
openwebui-db-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
llm-net:
|
||||||
|
name: llm-net
|
||||||
45
scripts/bootstrap.sh
Executable file
45
scripts/bootstrap.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Created .env from .env.example. Update secrets before production use."
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p config
|
||||||
|
if [[ ! -f config/kubeconfig ]]; then
|
||||||
|
echo "No config/kubeconfig found. Create one or set KUBECONFIG_PATH in .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Pulling base images..."
|
||||||
|
docker compose -f docker-compose.yaml -f docker-compose.mcp.yaml pull || true
|
||||||
|
|
||||||
|
echo "Building custom MCP service images..."
|
||||||
|
docker compose -f docker-compose.yaml -f docker-compose.mcp.yaml build mcp-filesystem mcp-k8s mcp-media
|
||||||
|
|
||||||
|
echo "Starting full stack..."
|
||||||
|
docker compose -f docker-compose.yaml -f docker-compose.mcp.yaml up -d
|
||||||
|
|
||||||
|
echo "Waiting for Ollama to become available..."
|
||||||
|
for _ in {1..30}; do
|
||||||
|
if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
MODEL_NAME="$(grep -E '^OLLAMA_DEFAULT_MODEL=' .env | cut -d'=' -f2- || true)"
|
||||||
|
MODEL_NAME="${MODEL_NAME:-llama3.1:8b}"
|
||||||
|
|
||||||
|
echo "Ensuring Ollama model is available: ${MODEL_NAME}"
|
||||||
|
docker compose exec -T ollama ollama pull "$MODEL_NAME" || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Stack is up."
|
||||||
|
echo "Open WebUI: http://localhost:11435"
|
||||||
|
echo "LiteLLM: http://localhost:4000"
|
||||||
|
echo "MCPO: http://localhost:8001"
|
||||||
|
echo "ContextForge (tools profile): http://localhost:8787"
|
||||||
12
services/mcp-filesystem/Dockerfile
Normal file
12
services/mcp-filesystem/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
|
CMD ["python", "/app/server.py"]
|
||||||
1
services/mcp-filesystem/requirements.txt
Normal file
1
services/mcp-filesystem/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mcp[cli]>=1.10.0
|
||||||
87
services/mcp-filesystem/server.py
Normal file
87
services/mcp-filesystem/server.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("filesystem", host="0.0.0.0", port=8010)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_roots() -> list[Path]:
|
||||||
|
roots_raw = os.getenv("MCP_FILESYSTEM_ROOTS", "/workspace").split(",")
|
||||||
|
roots = [Path(root.strip()).resolve() for root in roots_raw if root.strip()]
|
||||||
|
return roots or [Path("/workspace").resolve()]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(path: str) -> Path:
|
||||||
|
candidate = Path(path)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = Path("/workspace") / candidate
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
|
||||||
|
for root in _allowed_roots():
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
return resolved
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
allowed = ", ".join(str(r) for r in _allowed_roots())
|
||||||
|
raise ValueError(f"Path '{resolved}' is outside allowed roots: {allowed}")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_roots() -> list[str]:
|
||||||
|
"""List configured filesystem roots that this MCP server can access."""
|
||||||
|
return [str(root) for root in _allowed_roots()]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_dir(path: str = ".") -> list[str]:
|
||||||
|
"""List entries in a directory relative to an allowed root."""
|
||||||
|
target = _resolve(path)
|
||||||
|
if not target.exists() or not target.is_dir():
|
||||||
|
raise ValueError(f"Not a directory: {target}")
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for entry in sorted(target.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
suffix = "/" if entry.is_dir() else ""
|
||||||
|
items.append(f"{entry.name}{suffix}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def read_text(path: str, max_chars: int = 50000) -> str:
|
||||||
|
"""Read text from a file in an allowed root."""
|
||||||
|
target = _resolve(path)
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
|
raise ValueError(f"Not a file: {target}")
|
||||||
|
|
||||||
|
content = target.read_text(encoding="utf-8")
|
||||||
|
return content[:max_chars]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def write_text(path: str, content: str, append: bool = False) -> str:
|
||||||
|
"""Write text to a file in an allowed root."""
|
||||||
|
target = _resolve(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
mode = "a" if append else "w"
|
||||||
|
with target.open(mode, encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
|
||||||
|
return str(target)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def find_files(path: str = ".", pattern: str = "*") -> list[str]:
|
||||||
|
"""Find files by glob pattern under a path in an allowed root."""
|
||||||
|
target = _resolve(path)
|
||||||
|
if not target.exists() or not target.is_dir():
|
||||||
|
raise ValueError(f"Not a directory: {target}")
|
||||||
|
|
||||||
|
return [str(p) for p in target.rglob(pattern) if p.is_file()]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="sse")
|
||||||
12
services/mcp-k8s/Dockerfile
Normal file
12
services/mcp-k8s/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
EXPOSE 8012
|
||||||
|
|
||||||
|
CMD ["python", "/app/server.py"]
|
||||||
2
services/mcp-k8s/requirements.txt
Normal file
2
services/mcp-k8s/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mcp[cli]>=1.10.0
|
||||||
|
kubernetes>=30.1.0
|
||||||
78
services/mcp-k8s/server.py
Normal file
78
services/mcp-k8s/server.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import os
|
||||||
|
from kubernetes import client, config
|
||||||
|
from kubernetes.client.exceptions import ApiException
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("k8s-cluster", host="0.0.0.0", port=8012)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_kube() -> None:
|
||||||
|
kubeconfig = os.getenv("KUBECONFIG")
|
||||||
|
try:
|
||||||
|
if kubeconfig and os.path.exists(kubeconfig):
|
||||||
|
config.load_kube_config(config_file=kubeconfig)
|
||||||
|
else:
|
||||||
|
config.load_incluster_config()
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Failed to load Kubernetes config: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_namespaces() -> list[str]:
|
||||||
|
"""List all Kubernetes namespaces visible to this server."""
|
||||||
|
_load_kube()
|
||||||
|
api = client.CoreV1Api()
|
||||||
|
return [item.metadata.name for item in api.list_namespace().items]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_pods(namespace: str | None = None, limit: int = 100) -> list[dict]:
|
||||||
|
"""List pods in a namespace with status and node placement."""
|
||||||
|
_load_kube()
|
||||||
|
api = client.CoreV1Api()
|
||||||
|
ns = namespace or os.getenv("K8S_DEFAULT_NAMESPACE", "default")
|
||||||
|
|
||||||
|
pods = api.list_namespaced_pod(ns, limit=limit).items
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": pod.metadata.name,
|
||||||
|
"phase": pod.status.phase,
|
||||||
|
"node": pod.spec.node_name,
|
||||||
|
"created": pod.metadata.creation_timestamp.isoformat() if pod.metadata.creation_timestamp else None,
|
||||||
|
}
|
||||||
|
for pod in pods
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_pod_logs(namespace: str, pod_name: str, tail_lines: int = 200) -> str:
|
||||||
|
"""Get recent logs from a specific pod."""
|
||||||
|
_load_kube()
|
||||||
|
api = client.CoreV1Api()
|
||||||
|
return api.read_namespaced_pod_log(name=pod_name, namespace=namespace, tail_lines=tail_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_deployments(namespace: str | None = None) -> list[dict]:
|
||||||
|
"""List deployments in the namespace with availability details."""
|
||||||
|
_load_kube()
|
||||||
|
api = client.AppsV1Api()
|
||||||
|
ns = namespace or os.getenv("K8S_DEFAULT_NAMESPACE", "default")
|
||||||
|
|
||||||
|
try:
|
||||||
|
deps = api.list_namespaced_deployment(ns).items
|
||||||
|
except ApiException as exc:
|
||||||
|
raise RuntimeError(f"Kubernetes API error: {exc}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": dep.metadata.name,
|
||||||
|
"ready_replicas": dep.status.ready_replicas or 0,
|
||||||
|
"replicas": dep.status.replicas or 0,
|
||||||
|
}
|
||||||
|
for dep in deps
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="sse")
|
||||||
12
services/mcp-media/Dockerfile
Normal file
12
services/mcp-media/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
EXPOSE 8013
|
||||||
|
|
||||||
|
CMD ["python", "/app/server.py"]
|
||||||
2
services/mcp-media/requirements.txt
Normal file
2
services/mcp-media/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mcp[cli]>=1.10.0
|
||||||
|
requests>=2.32.3
|
||||||
83
services/mcp-media/server.py
Normal file
83
services/mcp-media/server.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("media-stack", host="0.0.0.0", port=8013)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str, headers: dict[str, str] | None = None, timeout: int = 15) -> Any:
|
||||||
|
response = requests.get(url, headers=headers or {}, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def service_health() -> dict[str, str]:
|
||||||
|
"""Check reachability of Radarr, Sonarr, qBittorrent, and Plex endpoints."""
|
||||||
|
checks = {
|
||||||
|
"radarr": (os.getenv("RADARR_URL", ""), "/api/v3/system/status", {"X-Api-Key": os.getenv("RADARR_API_KEY", "")}),
|
||||||
|
"sonarr": (os.getenv("SONARR_URL", ""), "/api/v3/system/status", {"X-Api-Key": os.getenv("SONARR_API_KEY", "")}),
|
||||||
|
"qbit": (os.getenv("QBIT_URL", ""), "/api/v2/app/version", {}),
|
||||||
|
"plex": (os.getenv("PLEX_URL", ""), "/identity", {"X-Plex-Token": os.getenv("PLEX_TOKEN", "")}),
|
||||||
|
}
|
||||||
|
|
||||||
|
status = {}
|
||||||
|
for name, (base, path, headers) in checks.items():
|
||||||
|
if not base:
|
||||||
|
status[name] = "not_configured"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
requests.get(f"{base.rstrip('/')}{path}", headers=headers, timeout=10).raise_for_status()
|
||||||
|
status[name] = "ok"
|
||||||
|
except Exception as exc:
|
||||||
|
status[name] = f"error: {exc}"
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def radarr_queue(page: int = 1, page_size: int = 20) -> Any:
|
||||||
|
"""Return current Radarr queue items."""
|
||||||
|
base = os.getenv("RADARR_URL", "")
|
||||||
|
key = os.getenv("RADARR_API_KEY", "")
|
||||||
|
url = f"{base.rstrip('/')}/api/v3/queue?page={page}&pageSize={page_size}"
|
||||||
|
return _get_json(url, headers={"X-Api-Key": key})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def sonarr_queue(page: int = 1, page_size: int = 20) -> Any:
|
||||||
|
"""Return current Sonarr queue items."""
|
||||||
|
base = os.getenv("SONARR_URL", "")
|
||||||
|
key = os.getenv("SONARR_API_KEY", "")
|
||||||
|
url = f"{base.rstrip('/')}/api/v3/queue?page={page}&pageSize={page_size}"
|
||||||
|
return _get_json(url, headers={"X-Api-Key": key})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def qbit_torrents(filter_name: str = "all") -> Any:
|
||||||
|
"""Return qBittorrent torrent list by filter (all/downloading/completed/etc)."""
|
||||||
|
base = os.getenv("QBIT_URL", "")
|
||||||
|
user = os.getenv("QBIT_USERNAME", "")
|
||||||
|
password = os.getenv("QBIT_PASSWORD", "")
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.post(f"{base.rstrip('/')}/api/v2/auth/login", data={"username": user, "password": password}, timeout=15).raise_for_status()
|
||||||
|
response = session.get(f"{base.rstrip('/')}/api/v2/torrents/info", params={"filter": filter_name}, timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def plex_sessions() -> Any:
|
||||||
|
"""Return active Plex sessions."""
|
||||||
|
base = os.getenv("PLEX_URL", "")
|
||||||
|
token = os.getenv("PLEX_TOKEN", "")
|
||||||
|
headers = {"X-Plex-Token": token, "Accept": "application/json"}
|
||||||
|
url = f"{base.rstrip('/')}/status/sessions"
|
||||||
|
return _get_json(url, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="sse")
|
||||||
Reference in New Issue
Block a user