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

455 lines (454 loc) • 20.6 kB
#!/usr/bin/env node /** * Hosted MCP connector: https://mcp.porkbun.com/mcp * * The remote endpoint ChatGPT and Claude connect to. Porkbun's authorization * server (porkbun.com/oauth2/*) issues the tokens; this is the resource server * that accepts them. * * Stateless by design: every POST /mcp builds a fresh MCP server bound to that * request's bearer token and throws it away afterwards. Nothing about a user is * held in memory between requests, so any number of these can run behind a load * balancer, and a restart drops nobody's session. * * The one thing that has to be right here is the 401. Both platforms discover * the authorization server from the WWW-Authenticate header on a 401, and both * refresh an expired token ONLY on a 401 — Claude does not honour the header on * a 200, and a tool error does not trigger a refresh. So the token is checked * before any MCP traffic is handled, rather than left for the API to reject * inside a tool call, which would surface as an error message instead of a * silent refresh. */ import { createHash } from "node:crypto"; import dns from "node:dns"; import http from "node:http"; import net from "node:net"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { loadConfig } from "./api.js"; import { buildServer, SERVER_VERSION } from "./server.js"; import { PROFILES, describeFor, redact } from "./profiles.js"; // Reach the API over IPv4 only. Every connector call leaves from this instance, // and the API exempts exactly one address from its per-IP rate limits: the // instance's Elastic IP. api.porkbun.com also has AAAA records and the instance // has an IPv6 address, so by default Node connects over IPv6, the exemption never // matches, and all ChatGPT and Claude users share one 2 req/s bucket (seen as // nginx 503s inside tool calls). ipv4first puts the A record first, and turning // off family autoselection (happy eyeballs) stops a slow v4 attempt from // quietly falling back to v6. dns.setDefaultResultOrder("ipv4first"); net.setDefaultAutoSelectFamily(false); const PORT = Number(process.env.PORT || 8787); const HOST = process.env.HOST || "127.0.0.1"; const PUBLIC_URL = (process.env.MCP_PUBLIC_URL || "https://mcp.porkbun.com").replace(/\/$/, ""); const ISSUER = (process.env.OAUTH_ISSUER || "https://porkbun.com").replace(/\/$/, ""); const PRM_BASE = `${PUBLIC_URL}/.well-known/oauth-protected-resource`; // /mcp keeps the bare metadata URL it has always advertised; the other paths use // the RFC 9728 form with the resource path appended. const prmUrlFor = (p) => (p.path === "/mcp" ? PRM_BASE : `${PRM_BASE}${p.path}`); const SCOPES = ["api", "offline_access"]; const MAX_BODY = 1024 * 1024; const TOKEN_CACHE_MS = 30_000; // OpenAI's app-directory domain verification: the portal issues a token that // must be served, alone and verbatim, at /.well-known/openai-apps-challenge on // the MCP host. Kept in the env file so a new token is a config change plus a // restart, not a code change. const OPENAI_APPS_CHALLENGE = (process.env.OPENAI_APPS_CHALLENGE || "").trim(); // Which tools each URL path offers (full set, or minus what a directory listing // forbids) lives in profiles.ts. Every path shares the same OAuth server, tokens // and API; only the tool list differs. const PROFILE_BY_PATH = new Map(PROFILES.map((p) => [p.path, p])); const baseConfig = { ...loadConfig(), apiKey: "", secretApiKey: "" }; // ── Token validation ───────────────────────────────────────────────────────── // token -> time the positive answer stops being trusted. Kept short so a // revoked or expired token is refused at the MCP layer (with a proper 401) // within seconds rather than only failing inside a tool call. const tokenCache = new Map(); // Validates the token and returns the connection's anonymous id (from /ping's // connectionId: a keyed hash of the connection's API key, stable across token // refreshes), or null when the token is not valid. Throws when the API cannot // be reached. async function tokenIsValid(token) { const cached = tokenCache.get(token); if (cached && cached.until > Date.now()) return { ok: true, conn: cached.conn }; try { // /ping authenticates the bearer the same way every endpoint does, so a // token that passes here is one the tools will accept. const res = await fetch(`${baseConfig.baseUrl}/ping`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": `${baseConfig.userAgent} (hosted)`, }, body: "{}", signal: AbortSignal.timeout(5000), }); const data = (await res.json().catch(() => ({}))); const ok = res.ok && data.status === "SUCCESS" && data.credentialsValid !== false; const conn = typeof data.connectionId === "string" && /^[a-f0-9]{8,64}$/.test(data.connectionId) ? data.connectionId : null; if (ok) { tokenCache.set(token, { until: Date.now() + TOKEN_CACHE_MS, conn }); if (tokenCache.size > 50_000) tokenCache.clear(); } else { tokenCache.delete(token); } return { ok, conn }; } catch { // Cannot reach the API: do not tell the client its token is bad (that // would make it throw the token away and re-prompt the user). A 503 lets it // retry with the same token. throw new Error("upstream"); } } // ── API key pairs ──────────────────────────────────────────────────────────── // For agent platforms that cannot complete OAuth but can send a fixed header: // the customer's own key pair, as X-API-Key + X-Secret-API-Key or // `Authorization: Bearer pk1_…:sk1_…`. The key's own limits (permissions, allowed // domains, spend caps) apply exactly as on the REST API. Validated with /ping like // a token; cached under a hash so the secret is never a map key. const keyCache = new Map(); function keyPairFrom(req, bearer) { const h = (n) => { const v = req.headers[n]; return typeof v === "string" ? v.trim() : ""; }; let apiKey = h("x-api-key"); let secretApiKey = h("x-secret-api-key"); if ((!apiKey || !secretApiKey) && bearer) { const m = /^(pk1_[A-Za-z0-9_]+):(sk1_[A-Za-z0-9_]+)$/.exec(bearer); if (m) [apiKey, secretApiKey] = [m[1], m[2]]; } if (!/^pk1_[A-Za-z0-9_]{8,200}$/.test(apiKey) || !/^sk1_[A-Za-z0-9_]{8,200}$/.test(secretApiKey)) return null; return { apiKey, secretApiKey }; } async function keyPairIsValid(k) { const id = createHash("sha256").update(k.apiKey + "\n" + k.secretApiKey).digest("hex"); const cached = keyCache.get(id); if (cached && cached.until > Date.now()) return { ok: true, conn: cached.conn }; try { const res = await fetch(`${baseConfig.baseUrl}/ping`, { method: "POST", headers: { "X-API-Key": k.apiKey, "X-Secret-API-Key": k.secretApiKey, "X-Porkbun-Connector": "1", "Content-Type": "application/json", "User-Agent": `${baseConfig.userAgent} (hosted)`, }, body: "{}", signal: AbortSignal.timeout(5000), }); const data = (await res.json().catch(() => ({}))); const ok = res.ok && data.status === "SUCCESS" && data.credentialsValid !== false; const conn = typeof data.connectionId === "string" && /^[a-f0-9]{8,64}$/.test(data.connectionId) ? data.connectionId : null; if (ok) { keyCache.set(id, { until: Date.now() + TOKEN_CACHE_MS, conn }); if (keyCache.size > 50_000) keyCache.clear(); } else { keyCache.delete(id); } return { ok, conn }; } catch { throw new Error("upstream"); } } // ── HTTP plumbing ──────────────────────────────────────────────────────────── function send(res, status, body, headers = {}) { const payload = typeof body === "string" ? body : JSON.stringify(body); res.writeHead(status, { "Content-Type": typeof body === "string" ? "text/plain; charset=utf-8" : "application/json", "Cache-Control": "no-store", ...headers, }); res.end(payload); } function unauthorized(res, invalidToken, profile) { const params = [`resource_metadata="${prmUrlFor(profile)}"`, `scope="${SCOPES.join(" ")}"`]; if (invalidToken) params.unshift(`error="invalid_token"`); send(res, 401, { error: invalidToken ? "invalid_token" : "unauthorized" }, { "WWW-Authenticate": `Bearer ${params.join(", ")}`, }); } function bearerFrom(req) { const h = req.headers["authorization"]; if (!h || Array.isArray(h)) return null; const m = /^Bearer\s+(\S+)$/i.exec(h.trim()); return m ? m[1] : null; } function readJson(req) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on("data", (c) => { size += c.length; if (size > MAX_BODY) { reject(new Error("too_large")); req.destroy(); return; } chunks.push(c); }); req.on("end", () => { try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined); } catch { reject(new Error("bad_json")); } }); req.on("error", reject); }); } // RFC 9728. `resource` must match the URL the user enters EXACTLY, path // included — Claude compares them. So each path has its own document. const protectedResourceMetadata = (p) => ({ resource: `${PUBLIC_URL}${p.path}`, authorization_servers: [ISSUER], scopes_supported: SCOPES, bearer_methods_supported: ["header"], resource_name: "Porkbun", resource_documentation: "https://porkbun.com/mcp", }); // Coarse family from the User-Agent; the raw string is not logged. function uaFamily(ua) { const u = ua.toLowerCase(); if (/codex/.test(u)) return "codex"; if (/openai|chatgpt/.test(u)) return "chatgpt"; if (/claude|anthropic/.test(u)) return "claude"; if (/cursor/.test(u)) return "cursor"; if (/vscode|copilot|visual studio code/.test(u)) return "vscode"; if (/cline/.test(u)) return "cline"; if (/windsurf|codeium/.test(u)) return "windsurf"; if (/python|httpx|aiohttp/.test(u)) return "python"; if (/node|undici|axios|mcp-remote/.test(u)) return "node"; if (/curl|wget/.test(u)) return "cli"; return ua ? "other" : "none"; } const str = (v, max = 80) => (typeof v === "string" ? v.slice(0, max) : undefined); // What the request asked for. Reads only method names, the tool name and // initialize's clientInfo, never params beyond that. function describeRequest(body, ev) { const msgs = Array.isArray(body) ? body : body ? [body] : []; for (const m of msgs) { if (!m || typeof m !== "object") continue; const msg = m; const method = str(msg.method, 60); if (!method) continue; ev.methods.push(method); if (method === "tools/call") ev.tools.push({ name: str(msg.params?.name, 64) ?? "?" }); if (method === "initialize") { const ci = msg.params?.clientInfo; if (ci) { ev.client = { name: str(ci.name, 60), version: str(ci.version, 40) }; // A client that names itself is a better guide than a bare User-Agent. if (ev.ua === "other" || ev.ua === "none" || ev.ua === "node") { const fam = uaFamily(String(ci.name ?? "")); if (fam !== "other" && fam !== "none") ev.ua = fam; } } } } } // How it ended, from the JSON response: per tools/call whether the result was // an error, and any JSON-RPC error codes. Responses come back in request order // for a batch; tools/call results are matched to tool names in that order. // Error messages are kept only for transport rejections (HTTP 4xx), which // describe the request's shape. A JSON-RPC error inside a 200 can be a // validation error (-32602) that quotes part of a tool argument, so only its // code is kept. function describeResponse(raw, ev, keepMessages) { let body; try { body = JSON.parse(raw); } catch { return; } const msgs = Array.isArray(body) ? body : [body]; let toolIdx = 0; for (const m of msgs) { if (!m || typeof m !== "object") continue; const msg = m; if (msg.error) { (ev.errors ??= []).push(keepMessages ? { code: msg.error.code, message: str(msg.error.message, 160) } : { code: msg.error.code }); if (ev.tools[toolIdx] && !ev.tools[toolIdx].outcome) ev.tools[toolIdx++].outcome = `rpc_error:${msg.error.code ?? "?"}`; } else if (msg.result && "content" in msg.result && ev.tools[toolIdx]) { ev.tools[toolIdx++].outcome = msg.result.isError ? "isError" : "ok"; } } } // ── Server ─────────────────────────────────────────────────────────────────── const server = http.createServer(async (req, res) => { const started = Date.now(); const url = new URL(req.url || "/", PUBLIC_URL); const path = url.pathname.replace(/\/+$/, "") || "/"; let ev = null; res.on("finish", () => { // Never log the token or bodies. const ts = new Date().toISOString(); const ms = Date.now() - started; console.log(`${ts} ${req.method} ${path} ${res.statusCode} ${ms}ms`); if (ev) console.log(`mcp_event ${JSON.stringify({ t: ts, status: res.statusCode, ms, ...ev })}`); }); try { // Bare form (= /mcp) and the path-suffixed form for every profile: clients probe either. if (req.method === "GET" && path.startsWith("/.well-known/oauth-protected-resource")) { const suffix = path.slice("/.well-known/oauth-protected-resource".length) || "/mcp"; const p = PROFILE_BY_PATH.get(suffix); if (!p) return send(res, 404, { error: "not_found" }); return send(res, 200, protectedResourceMetadata(p), { "Cache-Control": "public, max-age=3600" }); } if (req.method === "GET" && path === "/.well-known/openai-apps-challenge") { if (!OPENAI_APPS_CHALLENGE) return send(res, 404, "not configured"); return send(res, 200, OPENAI_APPS_CHALLENGE); } if (req.method === "GET" && path === "/health") { return send(res, 200, { ok: true, version: SERVER_VERSION }); } const profile = PROFILE_BY_PATH.get(path); if (!profile) { return send(res, 404, { error: "not_found" }); } ev = { path, methods: [], tools: [], ua: uaFamily(String(req.headers["user-agent"] ?? "")), proto: str(req.headers["mcp-protocol-version"], 20), }; const token = bearerFrom(req); const keys = token && token.startsWith("pbo_at_") ? null : keyPairFrom(req, token); if (!token && !keys) { ev.reason = "no_token"; return unauthorized(res, false, profile); } if (!keys && token && !token.startsWith("pbo_at_")) return unauthorized(res, true, profile); ev.auth = keys ? "api_key" : "oauth"; let valid; try { const check = keys ? await keyPairIsValid(keys) : await tokenIsValid(token); valid = check.ok; ev.conn = check.conn; } catch { ev.reason = "api_unreachable"; return send(res, 503, { error: "temporarily_unavailable" }, { "Retry-After": "5" }); } if (!valid) { ev.reason = keys ? "invalid_api_key" : "invalid_token"; return unauthorized(res, true, profile); } // Stateless: no standalone SSE stream to open (GET) and no session to end // (DELETE). Clients handle a 405 here as "not supported". if (req.method !== "POST") { ev.reason = `method_${req.method}`; return send(res, 405, { error: "method_not_allowed" }, { Allow: "POST" }); } let body; try { body = await readJson(req); } catch (e) { ev.reason = e.message; return send(res, e.message === "too_large" ? 413 : 400, { error: e.message }); } describeRequest(body, ev); // Keep a copy of the JSON response (capped) to read tool outcomes, error // codes and transport rejections from. Only summarised, never logged. const chunks = []; let captured = 0; const CAPTURE_MAX = 2 * 1024 * 1024; const keep = (c) => { if (c == null || captured > CAPTURE_MAX) return; // The SDK writes Uint8Array chunks (web streams), not always Buffers. const b = Buffer.isBuffer(c) ? c : c instanceof Uint8Array ? Buffer.from(c.buffer, c.byteOffset, c.byteLength) : Buffer.from(String(c)); captured += b.length; if (captured <= CAPTURE_MAX) chunks.push(b); }; const origWrite = res.write.bind(res); const origEnd = res.end.bind(res); res.write = (chunk, ...rest) => { keep(chunk); return origWrite(chunk, ...rest); }; res.end = (chunk, ...rest) => { if (chunk && typeof chunk !== "function") keep(chunk); if (ev && captured > 0 && captured <= CAPTURE_MAX) { describeResponse(Buffer.concat(chunks).toString("utf8"), ev, res.statusCode >= 400); if (res.statusCode >= 400 && !ev.reason) ev.reason = ev.errors?.[0]?.message ?? "transport_rejected"; } else if (ev && captured > CAPTURE_MAX) { for (const t of ev.tools) t.outcome ??= "unparsed_large"; } return origEnd(chunk, ...rest); }; const mcp = buildServer(() => (keys ? { ...baseConfig, apiKey: keys.apiKey, secretApiKey: keys.secretApiKey, viaConnector: true } : { ...baseConfig, bearerToken: token }), { exclude: profile.exclude, omitParams: profile.omitParams, hosted: true, describe: (name, description) => describeFor(profile, name, description), instructions: profile.instructions, transformResult: profile.redactKeys?.length ? (_name, result) => redact(result, profile.redactKeys, "[hidden on this connection; see API settings at porkbun.com/account/api]") : undefined, }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); res.on("close", () => { transport.close().catch(() => { }); mcp.close().catch(() => { }); }); await mcp.connect(transport); await transport.handleRequest(req, res, body); } catch (err) { console.error(`${new Date().toISOString()} error on ${req.method} ${path}:`, err instanceof Error ? err.message : err); if (!res.headersSent) send(res, 500, { error: "server_error" }); } }); server.listen(PORT, HOST, () => { console.log(`porkbun-mcp hosted connector on ${HOST}:${PORT} serving ${PROFILES.map((p) => PUBLIC_URL + p.path).join(", ")} (issuer ${ISSUER}, API ${baseConfig.baseUrl})`); }); for (const sig of ["SIGTERM", "SIGINT"]) { process.on(sig, () => server.close(() => process.exit(0))); }