commit 9fef20ad533791051cdb6798129d2377be0d864b Author: Mateusz Wójcik Date: Tue Jul 14 10:49:14 2026 +0200 initial commit diff --git a/config/contextforge/contextforge.yaml b/config/contextforge/contextforge.yaml new file mode 100644 index 0000000..73fa5b2 --- /dev/null +++ b/config/contextforge/contextforge.yaml @@ -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 diff --git a/config/kubeconfig.example b/config/kubeconfig.example new file mode 100644 index 0000000..e3045f5 --- /dev/null +++ b/config/kubeconfig.example @@ -0,0 +1,7 @@ +apiVersion: v1 +clusters: [] +contexts: [] +current-context: "" +kind: Config +preferences: {} +users: [] diff --git a/config/litellm/config.yaml b/config/litellm/config.yaml new file mode 100644 index 0000000..b1e755b --- /dev/null +++ b/config/litellm/config.yaml @@ -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} diff --git a/config/mcpo/mcpo.json b/config/mcpo/mcpo.json new file mode 100644 index 0000000..91f7611 --- /dev/null +++ b/config/mcpo/mcpo.json @@ -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" + } + } +} diff --git a/docker-compose.mcp.yaml b/docker-compose.mcp.yaml new file mode 100644 index 0000000..2fe9b90 --- /dev/null +++ b/docker-compose.mcp.yaml @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..24e5351 --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..7ed236a --- /dev/null +++ b/scripts/bootstrap.sh @@ -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" diff --git a/services/mcp-filesystem/Dockerfile b/services/mcp-filesystem/Dockerfile new file mode 100644 index 0000000..1da2750 --- /dev/null +++ b/services/mcp-filesystem/Dockerfile @@ -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"] diff --git a/services/mcp-filesystem/requirements.txt b/services/mcp-filesystem/requirements.txt new file mode 100644 index 0000000..f613035 --- /dev/null +++ b/services/mcp-filesystem/requirements.txt @@ -0,0 +1 @@ +mcp[cli]>=1.10.0 diff --git a/services/mcp-filesystem/server.py b/services/mcp-filesystem/server.py new file mode 100644 index 0000000..1402a08 --- /dev/null +++ b/services/mcp-filesystem/server.py @@ -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") diff --git a/services/mcp-k8s/Dockerfile b/services/mcp-k8s/Dockerfile new file mode 100644 index 0000000..ce46bd4 --- /dev/null +++ b/services/mcp-k8s/Dockerfile @@ -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"] diff --git a/services/mcp-k8s/requirements.txt b/services/mcp-k8s/requirements.txt new file mode 100644 index 0000000..335029c --- /dev/null +++ b/services/mcp-k8s/requirements.txt @@ -0,0 +1,2 @@ +mcp[cli]>=1.10.0 +kubernetes>=30.1.0 diff --git a/services/mcp-k8s/server.py b/services/mcp-k8s/server.py new file mode 100644 index 0000000..042cf39 --- /dev/null +++ b/services/mcp-k8s/server.py @@ -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") diff --git a/services/mcp-media/Dockerfile b/services/mcp-media/Dockerfile new file mode 100644 index 0000000..38347c5 --- /dev/null +++ b/services/mcp-media/Dockerfile @@ -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"] diff --git a/services/mcp-media/requirements.txt b/services/mcp-media/requirements.txt new file mode 100644 index 0000000..727be94 --- /dev/null +++ b/services/mcp-media/requirements.txt @@ -0,0 +1,2 @@ +mcp[cli]>=1.10.0 +requests>=2.32.3 diff --git a/services/mcp-media/server.py b/services/mcp-media/server.py new file mode 100644 index 0000000..3fcc622 --- /dev/null +++ b/services/mcp-media/server.py @@ -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")