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:
41
.gitea/workflows/ci.yml
Normal file
41
.gitea/workflows/ci.yml
Normal file
@@ -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
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.ruff_cache/
|
||||
uv.lock
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -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"]
|
||||
0
bunnycdn_mcp/__init__.py
Normal file
0
bunnycdn_mcp/__init__.py
Normal file
5
bunnycdn_mcp/__main__.py
Normal file
5
bunnycdn_mcp/__main__.py
Normal 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
66
bunnycdn_mcp/client.py
Normal 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
20
bunnycdn_mcp/config.py
Normal 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
12
bunnycdn_mcp/server.py
Normal 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)
|
||||
13
bunnycdn_mcp/tools/__init__.py
Normal file
13
bunnycdn_mcp/tools/__init__.py
Normal 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)
|
||||
35
bunnycdn_mcp/tools/cache.py
Normal file
35
bunnycdn_mcp/tools/cache.py
Normal 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}"
|
||||
91
bunnycdn_mcp/tools/pullzone.py
Normal file
91
bunnycdn_mcp/tools/pullzone.py
Normal 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}"
|
||||
79
bunnycdn_mcp/tools/shield.py
Normal file
79
bunnycdn_mcp/tools/shield.py
Normal 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}"
|
||||
47
bunnycdn_mcp/tools/statistics.py
Normal file
47
bunnycdn_mcp/tools/statistics.py
Normal 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}"
|
||||
55
k8s/deployment.yaml
Normal file
55
k8s/deployment.yaml
Normal file
@@ -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
|
||||
16
k8s/service.yaml
Normal file
16
k8s/service.yaml
Normal file
@@ -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
|
||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user