UNPKG

@porkbunllc/mcp-server

Version:

Official Porkbun MCP server, built and maintained by Porkbun — exposes the Porkbun v3 API as tools for AI agents (ChatGPT, Claude, Cursor, etc.)

140 lines (139 loc) • 6.52 kB
import { randomUUID } from "node:crypto"; const DEFAULT_BASE_URL = "https://api.porkbun.com/api/json/v3"; const DEFAULT_DOCS_BASE_URL = "https://porkbun.com"; export function loadConfig() { const apiKey = process.env.PORKBUN_API_KEY?.trim() ?? ""; const secretApiKey = process.env.PORKBUN_SECRET_API_KEY?.trim() ?? ""; const baseUrl = (process.env.PORKBUN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/$/, ""); const docsBaseUrl = (process.env.PORKBUN_DOCS_BASE?.trim() || DEFAULT_DOCS_BASE_URL).replace(/\/$/, ""); const userAgent = `porkbun-mcp/${process.env.npm_package_version ?? "0.1.0"}`; // Credentials are intentionally optional here: the documentation tools // (search_docs / read_doc / list_doc_topics) work with no setup, so the // server must start without keys. Tools that hit the authenticated API // enforce credentials at call time (see call()). return { apiKey, secretApiKey, baseUrl, docsBaseUrl, userAgent }; } /** * Fetch a public documentation resource (Markdown / plain text) from the * Porkbun website host — e.g. `/llms`, `/llms/dns`, `/llms-full.txt`. Docs are * unauthenticated and live at the site root (not under the API base), so no * credentials are sent. Returns the raw text. */ export async function fetchDoc(config, path) { const url = `${config.docsBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; const res = await fetch(url, { method: "GET", headers: { "User-Agent": config.userAgent, Accept: "text/markdown, text/plain, */*" }, }); const text = await res.text(); if (!res.ok) { throw new Error(`Porkbun docs request failed (HTTP ${res.status}) for ${url}`); } return text; } /** * POST an UNAUTHENTICATED v3 endpoint (no credentials attached) — used for * agentic onboarding like minting a sandbox key via /apikey/request. */ export async function callPublic(config, path, body = {}) { const url = `${config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; const res = await fetch(url, { method: "POST", headers: { "User-Agent": config.userAgent, Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(body), }); const text = await res.text(); try { return (text ? JSON.parse(text) : {}); } catch { throw new Error(`Porkbun API returned non-JSON response (HTTP ${res.status}): ${text.slice(0, 300)}`); } } /** * Call a Porkbun v3 API endpoint. The path is relative to the base URL and * should start with a slash (e.g. `/domain/listAll`). Auth is injected * automatically: header-based for GET, body-based for POST. */ /** An API error response, with its stable code and (for rate limits) seconds until the window resets. */ export class PorkbunApiError extends Error { code; ttlRemaining; constructor(message, code, ttlRemaining) { super(message); this.code = code; this.ttlRemaining = ttlRemaining; this.name = "PorkbunApiError"; } } export async function call(config, path, opts = {}) { return (await callWithPayment(config, path, opts)); } export async function callWithPayment(config, path, opts = {}) { // Authenticated endpoints require credentials; the documentation tools don't // go through here. Fail with a clear, actionable message rather than a 401. if (!config.bearerToken && (!config.apiKey || !config.secretApiKey)) { throw new Error("This tool needs Porkbun API credentials. Set PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY. " + "To get a key without leaving this conversation, call request_api_key (the user approves it in " + "their browser, optionally with limited permissions), then retrieve_api_key, then restart this " + "server with the new key; or create one at https://porkbun.com/account/api. For a test key with " + "fake credit, call create_sandbox_key. These, and the documentation tools (search_docs, read_doc, " + "list_doc_topics), work without credentials."); } const method = opts.method ?? "POST"; const url = `${config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; const headers = { "User-Agent": config.userAgent, Accept: "application/json", }; let body; if (config.bearerToken) { // Hosted connector: the token IS the credential. Nothing goes in the body — // the API only honours header auth when the body carries no key or token. headers["Authorization"] = `Bearer ${config.bearerToken}`; } if (method === "GET") { if (!config.bearerToken) { headers["X-API-Key"] = config.apiKey; headers["X-Secret-API-Key"] = config.secretApiKey; } } else { headers["Content-Type"] = "application/json"; body = JSON.stringify(config.bearerToken ? { ...(opts.body ?? {}) } : { ...(opts.body ?? {}), apikey: config.apiKey, secretapikey: config.secretApiKey }); if (opts.idempotencyKey) { headers["Idempotency-Key"] = opts.idempotencyKey; } else if (opts.idempotent) { headers["Idempotency-Key"] = randomUUID(); } } if (config.viaConnector) headers["X-Porkbun-Connector"] = "1"; Object.assign(headers, opts.headers ?? {}); const res = await fetch(url, { method, headers, body }); const text = await res.text(); let parsed; try { parsed = text ? JSON.parse(text) : {}; } catch { throw new Error(`Porkbun API returned non-JSON response (HTTP ${res.status}): ${text.slice(0, 300)}`); } const data = parsed; if (opts.allowPaymentRequired && res.status === 402 && data.code === "PAYMENT_REQUIRED") { return { paymentRequired: true, header: res.headers.get("payment-required"), data: parsed }; } if (!res.ok || data.status === "ERROR") { const code = data.code ? ` [${data.code}]` : ""; const msg = data.message ?? `HTTP ${res.status}`; // Surface the API's machine remediation hint so the agent can self-correct. const na = data.next_action?.hint ? ` — next: ${data.next_action.hint}${data.next_action.url ? ` (${data.next_action.url})` : ""}` : ""; throw new PorkbunApiError(`Porkbun API error${code}: ${msg}${na}`, data.code, typeof data.ttlRemaining === "number" ? data.ttlRemaining : undefined); } return parsed; }