Initial commit: BunnyCDN MCP server with K8s deployment

- 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)
This commit is contained in:
kappa
2026-02-15 15:19:42 +09:00
commit 7269686179
16 changed files with 530 additions and 0 deletions

0
bunnycdn_mcp/__init__.py Normal file
View File

5
bunnycdn_mcp/__main__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Entrypoint: python -m bunnycdn_mcp"""
from .server import mcp
mcp.run(transport="streamable-http")

66
bunnycdn_mcp/client.py Normal file
View File

@@ -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()

20
bunnycdn_mcp/config.py Normal file
View File

@@ -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")

12
bunnycdn_mcp/server.py Normal file
View File

@@ -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)

View File

@@ -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)

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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}"