feat: Initial Porkbun DNS MCP Light server
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m32s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m32s
- 3 specific tools: ping, list_domains, list_dns_records - 1 pass-through tool: porkbun_api for full API access - Safety system with READ/WRITE/INFRA access levels - Embedded API documentation as MCP resource - Starlette wrapper with /health endpoint - Gitea Actions CI workflow for Docker build
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Porkbun API Credentials
|
||||||
|
# Get your keys at: https://porkbun.com/account/api
|
||||||
|
PORKBUN_API_KEY=pk1_your_api_key_here
|
||||||
|
PORKBUN_SECRET_KEY=sk1_your_secret_key_here
|
||||||
|
|
||||||
|
# Optional: API base URL (default shown)
|
||||||
|
# PORKBUN_API_BASE=https://api.porkbun.com/api/json/v3
|
||||||
|
|
||||||
|
# Safety Controls
|
||||||
|
# Default: read-only mode (safest)
|
||||||
|
PORKBUN_ALLOW_WRITES=false
|
||||||
|
PORKBUN_ALLOW_INFRA=false
|
||||||
|
|
||||||
|
# Safety Levels:
|
||||||
|
# - READ (default): List domains, retrieve records, ping
|
||||||
|
# - WRITE: Enable DNS record create/edit/delete, DNSSEC, URL forwarding
|
||||||
|
# - INFRA: Enable nameserver and glue record changes (requires WRITE)
|
||||||
35
.gitea/workflows/build.yaml
Normal file
35
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.ext.ben.io
|
||||||
|
username: b3nw
|
||||||
|
password: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gitea.ext.ben.io/b3nw/porkbun-dns-mcp:latest
|
||||||
|
gitea.ext.ben.io/b3nw/porkbun-dns-mcp:${{ github.sha }}
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install uv for fast dependency management
|
||||||
|
RUN pip install uv
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN uv pip install --system -e .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import httpx; r = httpx.get('http://localhost:8000/health'); exit(0 if r.status_code == 200 else 1)"
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
CMD ["python", "server.py"]
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Porkbun DNS MCP
|
||||||
|
|
||||||
|
A lightweight MCP server for managing DNS records via the Porkbun API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **3 specific tools** for common operations (ping, list domains, list DNS records)
|
||||||
|
- **1 pass-through tool** for full API access
|
||||||
|
- **Safety system** with READ/WRITE/INFRA access levels
|
||||||
|
- **Embedded API documentation** as MCP resource
|
||||||
|
|
||||||
|
## Safety Levels
|
||||||
|
|
||||||
|
| Level | Operations | Environment Variable |
|
||||||
|
|-------|-----------|---------------------|
|
||||||
|
| READ | List domains, retrieve records, ping | Default |
|
||||||
|
| WRITE | Create/edit/delete DNS records, DNSSEC | `PORKBUN_ALLOW_WRITES=true` |
|
||||||
|
| INFRA | Nameserver changes, glue records | `PORKBUN_ALLOW_INFRA=true` |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create .env from template
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Add your Porkbun API keys to .env
|
||||||
|
# Get keys at: https://porkbun.com/account/api
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
uv pip install -e .
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-e PORKBUN_API_KEY=pk1_xxx \
|
||||||
|
-e PORKBUN_SECRET_KEY=sk1_xxx \
|
||||||
|
-e PORKBUN_ALLOW_WRITES=false \
|
||||||
|
-p 8000:8000 \
|
||||||
|
gitea.ext.ben.io/b3nw/porkbun-dns-mcp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
### `ping`
|
||||||
|
Test API connectivity and return your public IP address.
|
||||||
|
|
||||||
|
### `list_domains`
|
||||||
|
List all domains in your Porkbun account.
|
||||||
|
|
||||||
|
### `list_dns_records`
|
||||||
|
Retrieve DNS records for a specific domain.
|
||||||
|
|
||||||
|
### `porkbun_api`
|
||||||
|
Execute any Porkbun API call. Refer to the `porkbun://api-reference` resource for endpoint documentation.
|
||||||
|
|
||||||
|
## MCP Resources
|
||||||
|
|
||||||
|
- `porkbun://api-reference` - Full API documentation
|
||||||
|
- `porkbun://safety-status` - Current safety configuration
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `PORKBUN_API_KEY` | Yes | - | Porkbun API key |
|
||||||
|
| `PORKBUN_SECRET_KEY` | Yes | - | Porkbun secret key |
|
||||||
|
| `PORKBUN_ALLOW_WRITES` | No | `false` | Enable write operations |
|
||||||
|
| `PORKBUN_ALLOW_INFRA` | No | `false` | Enable infrastructure changes |
|
||||||
|
| `PORT` | No | `8000` | Server port |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /health` - Health check
|
||||||
|
- `POST /mcp/` - MCP SSE endpoint
|
||||||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[project]
|
||||||
|
name = "porkbun-dns-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP Light server for Porkbun DNS management"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.0.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"starlette>=0.40.0",
|
||||||
|
"uvicorn>=0.32.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["."]
|
||||||
|
include = ["server.py"]
|
||||||
418
server.py
Normal file
418
server.py
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
"""Porkbun DNS MCP Light Server.
|
||||||
|
|
||||||
|
A lightweight MCP server for managing DNS records via the Porkbun API.
|
||||||
|
Implements a safety system with READ, WRITE, and INFRA access levels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Mount, Route
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PORKBUN_API_KEY = os.getenv("PORKBUN_API_KEY", "")
|
||||||
|
PORKBUN_SECRET_KEY = os.getenv("PORKBUN_SECRET_KEY", "")
|
||||||
|
PORKBUN_API_BASE = os.getenv("PORKBUN_API_BASE", "https://api.porkbun.com/api/json/v3")
|
||||||
|
|
||||||
|
# Safety controls (defaults to read-only)
|
||||||
|
ALLOW_WRITES = os.getenv("PORKBUN_ALLOW_WRITES", "false").lower() == "true"
|
||||||
|
ALLOW_INFRA = os.getenv("PORKBUN_ALLOW_INFRA", "false").lower() == "true"
|
||||||
|
|
||||||
|
# Endpoint classifications for safety system
|
||||||
|
INFRA_ENDPOINTS = [
|
||||||
|
"/domain/updateNs/",
|
||||||
|
"/domain/createGlue/",
|
||||||
|
"/domain/updateGlue/",
|
||||||
|
"/domain/deleteGlue/",
|
||||||
|
]
|
||||||
|
|
||||||
|
WRITE_ENDPOINTS = [
|
||||||
|
"/dns/create/",
|
||||||
|
"/dns/edit/",
|
||||||
|
"/dns/editByNameType/",
|
||||||
|
"/dns/delete/",
|
||||||
|
"/dns/deleteByNameType/",
|
||||||
|
"/dns/createDnssecRecord/",
|
||||||
|
"/dns/deleteDnssecRecord/",
|
||||||
|
"/domain/addUrlForward/",
|
||||||
|
"/domain/deleteUrlForward/",
|
||||||
|
]
|
||||||
|
|
||||||
|
# API Documentation for agent self-service
|
||||||
|
API_DOCS = """
|
||||||
|
# Porkbun API Reference
|
||||||
|
|
||||||
|
Base URL: https://api.porkbun.com/api/json/v3
|
||||||
|
All endpoints use POST method. Authentication is handled automatically.
|
||||||
|
|
||||||
|
## Safety Levels
|
||||||
|
- READ: Default, all retrieve/list operations
|
||||||
|
- WRITE: Requires PORKBUN_ALLOW_WRITES=true - DNS record modifications
|
||||||
|
- INFRA: Requires PORKBUN_ALLOW_INFRA=true - Nameserver and glue record changes
|
||||||
|
|
||||||
|
## DNS Record Management
|
||||||
|
|
||||||
|
### Create DNS Record
|
||||||
|
Endpoint: /dns/create/{DOMAIN}
|
||||||
|
Body: {"name": "subdomain", "type": "A", "content": "1.2.3.4", "ttl": "600", "prio": "0"}
|
||||||
|
- name: Subdomain (blank for root, * for wildcard)
|
||||||
|
- type: A, AAAA, MX, CNAME, ALIAS, TXT, NS, SRV, TLSA, CAA, HTTPS, SVCB, SSHFP
|
||||||
|
- content: Record value
|
||||||
|
- ttl: Time to live (minimum 600)
|
||||||
|
- prio: Priority (for MX, SRV)
|
||||||
|
|
||||||
|
### Edit DNS Record by ID
|
||||||
|
Endpoint: /dns/edit/{DOMAIN}/{RECORD_ID}
|
||||||
|
Body: {"name": "subdomain", "type": "A", "content": "1.2.3.5", "ttl": "600"}
|
||||||
|
|
||||||
|
### Edit DNS Record by Name/Type
|
||||||
|
Endpoint: /dns/editByNameType/{DOMAIN}/{TYPE}/{SUBDOMAIN}
|
||||||
|
Body: {"content": "1.2.3.5", "ttl": "600"}
|
||||||
|
|
||||||
|
### Delete DNS Record by ID
|
||||||
|
Endpoint: /dns/delete/{DOMAIN}/{RECORD_ID}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Delete DNS Record by Name/Type
|
||||||
|
Endpoint: /dns/deleteByNameType/{DOMAIN}/{TYPE}/{SUBDOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Retrieve All DNS Records
|
||||||
|
Endpoint: /dns/retrieve/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Retrieve DNS Record by ID
|
||||||
|
Endpoint: /dns/retrieve/{DOMAIN}/{RECORD_ID}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Retrieve DNS Records by Name/Type
|
||||||
|
Endpoint: /dns/retrieveByNameType/{DOMAIN}/{TYPE}/{SUBDOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
## DNSSEC Management
|
||||||
|
|
||||||
|
### Create DNSSEC Record
|
||||||
|
Endpoint: /dns/createDnssecRecord/{DOMAIN}
|
||||||
|
Body: {"keyTag": "64087", "alg": "13", "digestType": "2", "digest": "..."}
|
||||||
|
|
||||||
|
### Get DNSSEC Records
|
||||||
|
Endpoint: /dns/getDnssecRecords/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Delete DNSSEC Record
|
||||||
|
Endpoint: /dns/deleteDnssecRecord/{DOMAIN}/{KEYTAG}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
## Domain Management
|
||||||
|
|
||||||
|
### List All Domains
|
||||||
|
Endpoint: /domain/listAll
|
||||||
|
Body: {"start": "0", "includeLabels": "yes"}
|
||||||
|
|
||||||
|
### Get Nameservers
|
||||||
|
Endpoint: /domain/getNs/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Update Nameservers (INFRA level)
|
||||||
|
Endpoint: /domain/updateNs/{DOMAIN}
|
||||||
|
Body: {"ns": ["ns1.example.com", "ns2.example.com"]}
|
||||||
|
|
||||||
|
### Check Domain Availability
|
||||||
|
Endpoint: /domain/checkDomain/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
## URL Forwarding
|
||||||
|
|
||||||
|
### Add URL Forward
|
||||||
|
Endpoint: /domain/addUrlForward/{DOMAIN}
|
||||||
|
Body: {"subdomain": "", "location": "https://example.com", "type": "temporary", "includePath": "no", "wildcard": "yes"}
|
||||||
|
|
||||||
|
### Get URL Forwarding
|
||||||
|
Endpoint: /domain/getUrlForwarding/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Delete URL Forward
|
||||||
|
Endpoint: /domain/deleteUrlForward/{DOMAIN}/{RECORD_ID}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
## Glue Records (INFRA level)
|
||||||
|
|
||||||
|
### Create Glue Record
|
||||||
|
Endpoint: /domain/createGlue/{DOMAIN}/{SUBDOMAIN}
|
||||||
|
Body: {"ips": ["192.168.1.1", "2001:db8::1"]}
|
||||||
|
|
||||||
|
### Update Glue Record
|
||||||
|
Endpoint: /domain/updateGlue/{DOMAIN}/{SUBDOMAIN}
|
||||||
|
Body: {"ips": ["192.168.1.2"]}
|
||||||
|
|
||||||
|
### Delete Glue Record
|
||||||
|
Endpoint: /domain/deleteGlue/{DOMAIN}/{SUBDOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
### Get Glue Records
|
||||||
|
Endpoint: /domain/getGlue/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
|
||||||
|
## SSL Certificates
|
||||||
|
|
||||||
|
### Retrieve SSL Bundle
|
||||||
|
Endpoint: /ssl/retrieve/{DOMAIN}
|
||||||
|
Body: {}
|
||||||
|
Returns: certificatechain, privatekey, publickey
|
||||||
|
|
||||||
|
## Utility
|
||||||
|
|
||||||
|
### Ping (Test Auth)
|
||||||
|
Endpoint: /ping
|
||||||
|
Body: {}
|
||||||
|
Returns: Your public IP address
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PorkbunClient:
|
||||||
|
"""HTTP client for Porkbun API with safety controls."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, secret_key: str, base_url: str):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.client = httpx.Client(timeout=30.0)
|
||||||
|
|
||||||
|
def _get_auth_body(self) -> dict[str, str]:
|
||||||
|
"""Return authentication payload."""
|
||||||
|
return {
|
||||||
|
"apikey": self.api_key,
|
||||||
|
"secretapikey": self.secret_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _classify_endpoint(self, endpoint: str) -> str:
|
||||||
|
"""Classify endpoint by safety level."""
|
||||||
|
if any(endpoint.startswith(e) for e in INFRA_ENDPOINTS):
|
||||||
|
return "INFRA"
|
||||||
|
if any(endpoint.startswith(e) for e in WRITE_ENDPOINTS):
|
||||||
|
return "WRITE"
|
||||||
|
return "READ"
|
||||||
|
|
||||||
|
def _validate_safety(self, endpoint: str) -> tuple[bool, str | None]:
|
||||||
|
"""Check if endpoint is allowed under current safety settings."""
|
||||||
|
level = self._classify_endpoint(endpoint)
|
||||||
|
|
||||||
|
if level == "INFRA":
|
||||||
|
if not ALLOW_INFRA:
|
||||||
|
return False, (
|
||||||
|
"INFRA operation blocked. This endpoint modifies domain infrastructure "
|
||||||
|
"(nameservers/glue records). Set PORKBUN_ALLOW_INFRA=true to enable."
|
||||||
|
)
|
||||||
|
if not ALLOW_WRITES:
|
||||||
|
return False, (
|
||||||
|
"INFRA operations require PORKBUN_ALLOW_WRITES=true in addition to "
|
||||||
|
"PORKBUN_ALLOW_INFRA=true."
|
||||||
|
)
|
||||||
|
|
||||||
|
if level == "WRITE":
|
||||||
|
if not ALLOW_WRITES:
|
||||||
|
return False, (
|
||||||
|
"WRITE operation blocked. This endpoint modifies DNS records. "
|
||||||
|
"Set PORKBUN_ALLOW_WRITES=true to enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self, endpoint: str, body: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute API request with safety checks."""
|
||||||
|
# Normalize endpoint
|
||||||
|
if not endpoint.startswith("/"):
|
||||||
|
endpoint = f"/{endpoint}"
|
||||||
|
|
||||||
|
# Safety validation
|
||||||
|
allowed, error_msg = self._validate_safety(endpoint)
|
||||||
|
if not allowed:
|
||||||
|
return {
|
||||||
|
"status": "BLOCKED",
|
||||||
|
"reason": error_msg,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"category": self._classify_endpoint(endpoint),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build request body with auth
|
||||||
|
request_body = self._get_auth_body()
|
||||||
|
if body:
|
||||||
|
request_body.update(body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.base_url}{endpoint}",
|
||||||
|
json=request_body,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"status": "ERROR", "message": f"HTTP error: {e}"}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"status": "ERROR", "message": "Invalid JSON response from API"}
|
||||||
|
|
||||||
|
def ping(self) -> dict[str, Any]:
|
||||||
|
"""Test API connectivity."""
|
||||||
|
return self.request("/ping")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close HTTP client."""
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize client and MCP
|
||||||
|
client = PorkbunClient(PORKBUN_API_KEY, PORKBUN_SECRET_KEY, PORKBUN_API_BASE)
|
||||||
|
mcp = FastMCP("porkbun-dns")
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP Resources ---
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("porkbun://api-reference")
|
||||||
|
def get_api_docs() -> str:
|
||||||
|
"""Returns the Porkbun API documentation for using the porkbun_api tool."""
|
||||||
|
return API_DOCS
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("porkbun://safety-status")
|
||||||
|
def get_safety_status() -> str:
|
||||||
|
"""Returns current safety configuration."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"allow_writes": ALLOW_WRITES,
|
||||||
|
"allow_infra": ALLOW_INFRA,
|
||||||
|
"mode": "INFRA" if ALLOW_INFRA else ("WRITE" if ALLOW_WRITES else "READ"),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP Tools ---
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def ping() -> str:
|
||||||
|
"""Test API connectivity and return your public IP address.
|
||||||
|
|
||||||
|
Useful for verifying credentials and for dynamic DNS scenarios.
|
||||||
|
"""
|
||||||
|
result = client.ping()
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_domains(start: int = 0, include_labels: bool = False) -> str:
|
||||||
|
"""List all domains in your Porkbun account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Pagination offset (domains returned in chunks of 1000)
|
||||||
|
include_labels: Include domain labels in response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with domain list and details
|
||||||
|
"""
|
||||||
|
body = {"start": str(start)}
|
||||||
|
if include_labels:
|
||||||
|
body["includeLabels"] = "yes"
|
||||||
|
|
||||||
|
result = client.request("/domain/listAll", body)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_dns_records(domain: str, record_id: str | None = None) -> str:
|
||||||
|
"""Retrieve DNS records for a domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: The domain name (e.g., 'example.com')
|
||||||
|
record_id: Optional specific record ID to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with DNS records
|
||||||
|
"""
|
||||||
|
endpoint = f"/dns/retrieve/{domain}"
|
||||||
|
if record_id:
|
||||||
|
endpoint = f"{endpoint}/{record_id}"
|
||||||
|
|
||||||
|
result = client.request(endpoint)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def porkbun_api(endpoint: str, body: str = "{}") -> str:
|
||||||
|
"""Execute a raw Porkbun API call.
|
||||||
|
|
||||||
|
Use the 'porkbun://api-reference' resource for endpoint documentation.
|
||||||
|
Safety controls apply: WRITE and INFRA operations may be blocked.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API path (e.g., '/dns/create/example.com')
|
||||||
|
body: JSON string of additional parameters (auth is added automatically)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with API response
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed_body = json.loads(body) if body else {}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return json.dumps({"status": "ERROR", "message": f"Invalid JSON body: {e}"})
|
||||||
|
|
||||||
|
result = client.request(endpoint, parsed_body)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health Check & App ---
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request):
|
||||||
|
"""Health check endpoint for Docker/orchestration."""
|
||||||
|
if not PORKBUN_API_KEY or not PORKBUN_SECRET_KEY:
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "unhealthy", "reason": "Missing API credentials"},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test API connectivity
|
||||||
|
result = client.ping()
|
||||||
|
if result.get("status") == "SUCCESS":
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"mode": "INFRA"
|
||||||
|
if ALLOW_INFRA
|
||||||
|
else ("WRITE" if ALLOW_WRITES else "READ"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "unhealthy", "reason": result.get("message", "API ping failed")},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Starlette:
|
||||||
|
"""Create the ASGI application."""
|
||||||
|
mcp_app = mcp.http_app()
|
||||||
|
return Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/health", health),
|
||||||
|
Mount("/", app=mcp_app),
|
||||||
|
],
|
||||||
|
lifespan=mcp_app.lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
port = int(os.getenv("PORT", "8000"))
|
||||||
|
uvicorn.run(create_app(), host="0.0.0.0", port=port)
|
||||||
Reference in New Issue
Block a user