@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
JavaScript
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;
}