From 7269686179ec807818b33e0a07fb71aaab5b9eb5 Mon Sep 17 00:00:00 2001 From: kappa Date: Sun, 15 Feb 2026 15:19:42 +0900 Subject: [PATCH] Initial commit: BunnyCDN MCP server with K8s deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastMCP server with 12 tools (pullzone, cache, statistics, shield) - Dockerfile with multi-stage build (python:3.11-slim + uv) - K8s manifests (Deployment + Service) - Gitea Actions CI/CD pipeline (build → push → deploy) --- .gitea/workflows/ci.yml | 41 ++++++++++++++ .gitignore | 8 +++ Dockerfile | 23 ++++++++ bunnycdn_mcp/__init__.py | 0 bunnycdn_mcp/__main__.py | 5 ++ bunnycdn_mcp/client.py | 66 +++++++++++++++++++++++ bunnycdn_mcp/config.py | 20 +++++++ bunnycdn_mcp/server.py | 12 +++++ bunnycdn_mcp/tools/__init__.py | 13 +++++ bunnycdn_mcp/tools/cache.py | 35 ++++++++++++ bunnycdn_mcp/tools/pullzone.py | 91 ++++++++++++++++++++++++++++++++ bunnycdn_mcp/tools/shield.py | 79 +++++++++++++++++++++++++++ bunnycdn_mcp/tools/statistics.py | 47 +++++++++++++++++ k8s/deployment.yaml | 55 +++++++++++++++++++ k8s/service.yaml | 16 ++++++ pyproject.toml | 19 +++++++ 16 files changed, 530 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bunnycdn_mcp/__init__.py create mode 100644 bunnycdn_mcp/__main__.py create mode 100644 bunnycdn_mcp/client.py create mode 100644 bunnycdn_mcp/config.py create mode 100644 bunnycdn_mcp/server.py create mode 100644 bunnycdn_mcp/tools/__init__.py create mode 100644 bunnycdn_mcp/tools/cache.py create mode 100644 bunnycdn_mcp/tools/pullzone.py create mode 100644 bunnycdn_mcp/tools/shield.py create mode 100644 bunnycdn_mcp/tools/statistics.py create mode 100644 k8s/deployment.yaml create mode 100644 k8s/service.yaml create mode 100644 pyproject.toml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..20586f9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI/CD + +on: + push: + branches: [main] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Gitea Registry + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.anvil.it.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + + - name: Build and push Docker image + run: | + IMAGE=gitea.anvil.it.com/kaffa/bunnycdn-mcp + TAG=${GITHUB_SHA::8} + docker build \ + --tag ${IMAGE}:${TAG} \ + --tag ${IMAGE}:latest \ + . + docker push ${IMAGE}:${TAG} + docker push ${IMAGE}:latest + + - name: Deploy to K8s + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + IMAGE=gitea.anvil.it.com/kaffa/bunnycdn-mcp + TAG=${GITHUB_SHA::8} + + kubectl apply -f k8s/ + kubectl set image deployment/bunnycdn-mcp \ + bunnycdn-mcp=${IMAGE}:${TAG} \ + -n default + kubectl rollout status deployment/bunnycdn-mcp \ + -n default --timeout=120s diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc9d8df --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +*.egg-info/ +dist/ +build/ +.ruff_cache/ +uv.lock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6bee28b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim AS builder + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +COPY pyproject.toml uv.lock* ./ + +RUN uv pip install --system --no-cache -e . || uv pip install --system --no-cache . + +COPY bunnycdn_mcp/ ./bunnycdn_mcp/ + +FROM python:3.11-slim + +WORKDIR /app + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin +COPY --from=builder /app/bunnycdn_mcp ./bunnycdn_mcp + +EXPOSE 8001 + +ENTRYPOINT ["python", "-m", "bunnycdn_mcp"] diff --git a/bunnycdn_mcp/__init__.py b/bunnycdn_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bunnycdn_mcp/__main__.py b/bunnycdn_mcp/__main__.py new file mode 100644 index 0000000..c395642 --- /dev/null +++ b/bunnycdn_mcp/__main__.py @@ -0,0 +1,5 @@ +"""Entrypoint: python -m bunnycdn_mcp""" + +from .server import mcp + +mcp.run(transport="streamable-http") diff --git a/bunnycdn_mcp/client.py b/bunnycdn_mcp/client.py new file mode 100644 index 0000000..15d9870 --- /dev/null +++ b/bunnycdn_mcp/client.py @@ -0,0 +1,66 @@ +"""BunnyCDN REST API client using httpx.""" + +import httpx + +from .config import BUNNY_API_BASE, BUNNY_API_KEY, REQUEST_TIMEOUT, logger + + +class BunnyClient: + """Async HTTP client for BunnyCDN API.""" + + def __init__(self) -> None: + self._base = BUNNY_API_BASE + self._headers = { + "AccessKey": BUNNY_API_KEY, + "Accept": "application/json", + "Content-Type": "application/json", + } + + async def get(self, path: str, params: dict | None = None) -> dict | list: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as c: + resp = await c.get( + f"{self._base}{path}", + headers=self._headers, + params=params, + ) + return self._handle(resp) + + async def post(self, path: str, json: dict | None = None) -> dict | list | str: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as c: + resp = await c.post( + f"{self._base}{path}", + headers=self._headers, + json=json, + ) + return self._handle(resp) + + async def delete(self, path: str) -> dict | str: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as c: + resp = await c.delete( + f"{self._base}{path}", + headers=self._headers, + ) + return self._handle(resp) + + def _handle(self, resp: httpx.Response) -> dict | list | str: + if resp.status_code == 401: + raise BunnyAPIError("Authentication failed: invalid API key") + if resp.status_code == 404: + raise BunnyAPIError(f"Not found: {resp.url}") + if resp.status_code >= 500: + raise BunnyAPIError(f"Server error {resp.status_code}: {resp.text}") + if resp.status_code >= 400: + raise BunnyAPIError(f"Client error {resp.status_code}: {resp.text}") + if not resp.content: + return "" + try: + return resp.json() + except Exception: + return resp.text + + +class BunnyAPIError(Exception): + pass + + +client = BunnyClient() diff --git a/bunnycdn_mcp/config.py b/bunnycdn_mcp/config.py new file mode 100644 index 0000000..23aebb8 --- /dev/null +++ b/bunnycdn_mcp/config.py @@ -0,0 +1,20 @@ +"""Configuration and logging for BunnyCDN MCP server.""" + +import logging +import os + +MCP_HOST = os.environ.get("MCP_HOST", "0.0.0.0") +MCP_PORT = int(os.environ.get("MCP_PORT", "8001")) + +BUNNY_API_KEY = os.environ.get("BUNNY_API_KEY", "") +BUNNY_API_BASE = "https://api.bunny.net" + +REQUEST_TIMEOUT = 60 + +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() + +logging.basicConfig( + level=LOG_LEVEL, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("bunnycdn-mcp") diff --git a/bunnycdn_mcp/server.py b/bunnycdn_mcp/server.py new file mode 100644 index 0000000..31f3c92 --- /dev/null +++ b/bunnycdn_mcp/server.py @@ -0,0 +1,12 @@ +"""FastMCP server initialization.""" + +from mcp.server.fastmcp import FastMCP + +from .config import MCP_HOST, MCP_PORT, logger +from .tools import register_all_tools + +mcp = FastMCP("bunnycdn", host=MCP_HOST, port=MCP_PORT) + +register_all_tools(mcp) + +logger.info("BunnyCDN MCP server initialized with %s:%s", MCP_HOST, MCP_PORT) diff --git a/bunnycdn_mcp/tools/__init__.py b/bunnycdn_mcp/tools/__init__.py new file mode 100644 index 0000000..80ed259 --- /dev/null +++ b/bunnycdn_mcp/tools/__init__.py @@ -0,0 +1,13 @@ +"""Tool registration dispatcher.""" + +from .cache import register_cache_tools +from .pullzone import register_pullzone_tools +from .shield import register_shield_tools +from .statistics import register_statistics_tools + + +def register_all_tools(mcp): + register_pullzone_tools(mcp) + register_cache_tools(mcp) + register_statistics_tools(mcp) + register_shield_tools(mcp) diff --git a/bunnycdn_mcp/tools/cache.py b/bunnycdn_mcp/tools/cache.py new file mode 100644 index 0000000..4f7d0a9 --- /dev/null +++ b/bunnycdn_mcp/tools/cache.py @@ -0,0 +1,35 @@ +"""Cache purge tools.""" + +from typing import Annotated + +from pydantic import Field + +from ..client import client +from ..config import logger + + +def register_cache_tools(mcp): + + @mcp.tool() + async def bunny_purge_zone( + pullzone_id: Annotated[int, Field(description="Pull Zone ID to purge all cache for")], + ) -> str: + """Purge the entire cache for a Pull Zone.""" + try: + await client.post(f"/pullzone/{pullzone_id}/purgeCache") + return f"Cache purged for Pull Zone {pullzone_id}" + except Exception as e: + logger.error("bunny_purge_zone failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_purge_url( + url: Annotated[str, Field(description="Full URL to purge from cache (e.g. https://cdn.example.com/path/to/file)")], + ) -> str: + """Purge a specific URL from the CDN cache.""" + try: + await client.get("/purge", params={"url": url}) + return f"Cache purged for URL: {url}" + except Exception as e: + logger.error("bunny_purge_url failed: %s", e) + return f"Error: {e}" diff --git a/bunnycdn_mcp/tools/pullzone.py b/bunnycdn_mcp/tools/pullzone.py new file mode 100644 index 0000000..30f2b22 --- /dev/null +++ b/bunnycdn_mcp/tools/pullzone.py @@ -0,0 +1,91 @@ +"""Pull Zone management tools.""" + +import json +from typing import Annotated + +from pydantic import Field + +from ..client import client +from ..config import logger + + +def register_pullzone_tools(mcp): + + @mcp.tool() + async def bunny_list_pullzones() -> str: + """List all Pull Zones in the account.""" + try: + data = await client.get("/pullzone") + zones = [] + for z in data: + zones.append({ + "Id": z.get("Id"), + "Name": z.get("Name"), + "OriginUrl": z.get("OriginUrl"), + "Enabled": z.get("Enabled"), + "Hostnames": [h.get("Value") for h in z.get("Hostnames", [])], + "MonthlyBandwidthUsed": z.get("MonthlyBandwidthUsed"), + "CacheQuality": z.get("CacheQuality"), + }) + return json.dumps(zones, indent=2) + except Exception as e: + logger.error("bunny_list_pullzones failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_get_pullzone( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + ) -> str: + """Get detailed information for a specific Pull Zone.""" + try: + data = await client.get(f"/pullzone/{pullzone_id}") + return json.dumps(data, indent=2) + except Exception as e: + logger.error("bunny_get_pullzone failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_update_pullzone( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + settings: Annotated[str, Field(description="JSON string of settings to update (e.g. '{\"OriginUrl\": \"https://new-origin.com\", \"EnableGeoZoneUS\": true}')")], + ) -> str: + """Update Pull Zone settings. Pass settings as a JSON string.""" + try: + parsed = json.loads(settings) + except json.JSONDecodeError as e: + return f"Error: Invalid JSON in settings: {e}" + try: + data = await client.post(f"/pullzone/{pullzone_id}", json=parsed) + return json.dumps(data, indent=2) if isinstance(data, dict) else str(data) + except Exception as e: + logger.error("bunny_update_pullzone failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_add_hostname( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + hostname: Annotated[str, Field(description="Custom hostname to add (e.g. cdn.example.com)")], + ) -> str: + """Add a custom hostname to a Pull Zone.""" + try: + await client.post("/pullzone/addHostname", json={ + "PullZoneId": pullzone_id, + "Hostname": hostname, + }) + return f"Hostname '{hostname}' added to Pull Zone {pullzone_id}" + except Exception as e: + logger.error("bunny_add_hostname failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_remove_hostname( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + hostname: Annotated[str, Field(description="Hostname to remove")], + ) -> str: + """Remove a hostname from a Pull Zone.""" + try: + await client.delete(f"/pullzone/deleteHostname?id={pullzone_id}&hostname={hostname}") + return f"Hostname '{hostname}' removed from Pull Zone {pullzone_id}" + except Exception as e: + logger.error("bunny_remove_hostname failed: %s", e) + return f"Error: {e}" diff --git a/bunnycdn_mcp/tools/shield.py b/bunnycdn_mcp/tools/shield.py new file mode 100644 index 0000000..7c45086 --- /dev/null +++ b/bunnycdn_mcp/tools/shield.py @@ -0,0 +1,79 @@ +"""WAF / DDoS Shield tools.""" + +import json +from typing import Annotated + +from pydantic import Field + +from ..client import client +from ..config import logger + + +def register_shield_tools(mcp): + + @mcp.tool() + async def bunny_shield_status( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + ) -> str: + """Get the Shield/WAF/DDoS protection status for a Pull Zone.""" + try: + zone = await client.get(f"/pullzone/{pullzone_id}") + status = { + "PullZoneId": zone.get("Id"), + "Name": zone.get("Name"), + "WAFEnabled": zone.get("WAFEnabled"), + "WAFEnabledRuleGroups": zone.get("WAFEnabledRuleGroups"), + "WAFDisabledRules": zone.get("WAFDisabledRules"), + "ShieldDDosProtectionEnabled": zone.get("ShieldDDosProtectionEnabled"), + "ShieldDDosProtectionType": zone.get("ShieldDDosProtectionType"), + "OriginShieldEnabled": zone.get("OriginShieldEnabled"), + "OriginShieldZoneCode": zone.get("OriginShieldZoneCode"), + } + return json.dumps(status, indent=2) + except Exception as e: + logger.error("bunny_shield_status failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_waf_rules( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + ) -> str: + """List WAF rule groups and their configuration for a Pull Zone.""" + try: + data = await client.get(f"/shield/waf/{pullzone_id}") + return json.dumps(data, indent=2) + except Exception as e: + logger.error("bunny_waf_rules failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_shield_update( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + settings: Annotated[str, Field(description="JSON string of shield settings to update (e.g. '{\"ShieldDDosProtectionEnabled\": true, \"WAFEnabled\": true}')")], + ) -> str: + """Update DDoS/WAF/Shield settings for a Pull Zone.""" + try: + parsed = json.loads(settings) + except json.JSONDecodeError as e: + return f"Error: Invalid JSON in settings: {e}" + try: + data = await client.post(f"/pullzone/{pullzone_id}", json=parsed) + return json.dumps(data, indent=2) if isinstance(data, dict) else f"Shield settings updated for Pull Zone {pullzone_id}" + except Exception as e: + logger.error("bunny_shield_update failed: %s", e) + return f"Error: {e}" + + @mcp.tool() + async def bunny_waf_logs( + pullzone_id: Annotated[int, Field(description="Pull Zone ID")], + page: Annotated[int, Field(default=1, description="Page number")] = 1, + per_page: Annotated[int, Field(default=25, description="Results per page (max 100)")] = 25, + ) -> str: + """Get WAF event logs for a Pull Zone.""" + try: + params = {"page": page, "perPage": min(per_page, 100)} + data = await client.get(f"/shield/waf/{pullzone_id}/logs", params=params) + return json.dumps(data, indent=2) + except Exception as e: + logger.error("bunny_waf_logs failed: %s", e) + return f"Error: {e}" diff --git a/bunnycdn_mcp/tools/statistics.py b/bunnycdn_mcp/tools/statistics.py new file mode 100644 index 0000000..c6d8fbf --- /dev/null +++ b/bunnycdn_mcp/tools/statistics.py @@ -0,0 +1,47 @@ +"""Statistics and analytics tools.""" + +import json +from typing import Annotated + +from pydantic import Field + +from ..client import client +from ..config import logger + + +def register_statistics_tools(mcp): + + @mcp.tool() + async def bunny_get_statistics( + pullzone_id: Annotated[int | None, Field(default=None, description="Pull Zone ID (optional, omit for account-wide stats)")] = None, + date_from: Annotated[str | None, Field(default=None, description="Start date in YYYY-MM-DD format (optional)")] = None, + date_to: Annotated[str | None, Field(default=None, description="End date in YYYY-MM-DD format (optional)")] = None, + ) -> str: + """Get bandwidth, cache hit rate, and request statistics.""" + try: + params = {} + if pullzone_id is not None: + params["pullZone"] = pullzone_id + if date_from: + params["dateFrom"] = date_from + if date_to: + params["dateTo"] = date_to + + data = await client.get("/statistics", params=params or None) + + summary = { + "TotalBandwidthUsed": data.get("TotalBandwidthUsed"), + "TotalRequestsServed": data.get("TotalRequestsServed"), + "CacheHitRate": data.get("CacheHitRate"), + "AverageOriginResponseTime": data.get("AverageOriginResponseTime"), + "BandwidthUsedChart": data.get("BandwidthUsedChart"), + "RequestsServedChart": data.get("RequestsServedChart"), + "CacheHitRateChart": data.get("CacheHitRateChart"), + "Error3xxChart": data.get("Error3xxChart"), + "Error4xxChart": data.get("Error4xxChart"), + "Error5xxChart": data.get("Error5xxChart"), + } + return json.dumps(summary, indent=2) + except Exception as e: + logger.error("bunny_get_statistics failed: %s", e) + return f"Error: {e}" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..5129169 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bunnycdn-mcp + namespace: default + labels: + app: bunnycdn-mcp +spec: + replicas: 1 + revisionHistoryLimit: 2 + selector: + matchLabels: + app: bunnycdn-mcp + template: + metadata: + labels: + app: bunnycdn-mcp + spec: + imagePullSecrets: + - name: gitea-registry + containers: + - name: bunnycdn-mcp + image: gitea.anvil.it.com/kaffa/bunnycdn-mcp:latest + ports: + - containerPort: 8001 + protocol: TCP + env: + - name: MCP_HOST + value: "0.0.0.0" + - name: MCP_PORT + value: "8001" + - name: BUNNY_API_KEY + valueFrom: + secretKeyRef: + name: bunnycdn-secrets + key: api-key + - name: LOG_LEVEL + value: "INFO" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + readinessProbe: + tcpSocket: + port: 8001 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: 8001 + initialDelaySeconds: 10 + periodSeconds: 30 diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..0414b75 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: bunnycdn-mcp + namespace: default + labels: + app: bunnycdn-mcp +spec: + type: ClusterIP + selector: + app: bunnycdn-mcp + ports: + - name: mcp + port: 8001 + targetPort: 8001 + protocol: TCP diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f75cb42 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "bunnycdn-mcp" +version = "0.1.0" +description = "BunnyCDN MCP Server - Manage CDN via Claude Code" +requires-python = ">=3.11" +dependencies = [ + "mcp[cli]>=1.0.0", + "httpx", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-asyncio", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build"