@porkbunllc/mcp-server 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the [Porkbun v3 API](https://porkbun.com/api/json/v3/documentation) as native tools for AI agents — Claude Desktop, Cursor, Cline, and any other MCP-compatible client.
4
4
 
5
- > **Status:** v0.1read-only tools only. Write operations (register, DNS edits, SSL provisioning) coming in subsequent releases.
5
+ > **Status:** v0.2full domain lifecycle (register, renew, transfer) plus DNS and nameserver writes. All write operations attach an `Idempotency-Key` automatically, so retries within 24 hours don't double-charge.
6
6
 
7
- ## What's included (v0.1)
7
+ ## What's included (v0.2)
8
+
9
+ **Read tools**
8
10
 
9
11
  | Tool | Description |
10
12
  |---|---|
@@ -16,6 +18,23 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
16
18
  | `list_dns_records` | List DNS records for a domain |
17
19
  | `get_ssl_bundle` | Retrieve the free Porkbun-issued SSL bundle for a domain |
18
20
 
21
+ **Write tools (spend account credit — register/renew/transfer)**
22
+
23
+ | Tool | Description |
24
+ |---|---|
25
+ | `register_domain` | Register a new domain — workflow: `check_domain` first to confirm price |
26
+ | `renew_domain` | Renew an existing domain |
27
+ | `transfer_domain` | Initiate an inbound transfer (returns transferId; takes 5-7 days) |
28
+
29
+ **DNS and nameserver writes (free)**
30
+
31
+ | Tool | Description |
32
+ |---|---|
33
+ | `create_dns_record` | Create a new DNS record (A, AAAA, CNAME, MX, TXT, etc.) |
34
+ | `update_dns_record` | Update an existing DNS record by its ID |
35
+ | `delete_dns_record` | Delete a DNS record by its ID |
36
+ | `update_nameservers` | Replace the nameserver list for a domain (full replace, not append) |
37
+
19
38
  ## Install
20
39
 
21
40
  You'll need [Node.js](https://nodejs.org) 18 or newer.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { loadConfig } from "./api.js";
5
5
  import { tools } from "./tools.js";
6
6
  const server = new McpServer({
7
7
  name: "porkbun-mcp",
8
- version: "0.1.0",
8
+ version: "0.2.0",
9
9
  });
10
10
  // Defer config loading until the first tool call. tools/list works without
11
11
  // credentials so MCP clients can still discover what's available.
@@ -19,6 +19,7 @@ for (const tool of tools) {
19
19
  server.registerTool(tool.name, {
20
20
  description: tool.description,
21
21
  inputSchema: tool.inputSchema,
22
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
22
23
  }, async (args) => {
23
24
  try {
24
25
  const result = await tool.handler(getConfig(), args);
package/dist/tools.js CHANGED
@@ -1,26 +1,29 @@
1
1
  import { z } from "zod";
2
2
  import { call } from "./api.js";
3
+ // ─── Read-only tools ────────────────────────────────────────────────────────
3
4
  const ping = {
4
5
  name: "ping",
5
6
  description: "Verify the Porkbun API connection and credentials. Returns the caller's public IP and whether the API key is valid. Use this as a first sanity check before making other calls.",
6
7
  inputSchema: {},
8
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
7
9
  handler: async (config) => {
8
10
  return await call(config, "/ping", { method: "POST" });
9
11
  },
10
12
  };
11
13
  const check_domain = {
12
14
  name: "check_domain",
13
- description: "Check whether a single domain is available for registration and what it costs. Returns availability, registration price, renewal price, transfer price, and (for premium domains) extended pricing details. Pricing is in USD. Use this BEFORE register_domain to confirm cost — Porkbun rejects registrations whose `cost` doesn't match the current quote.",
15
+ description: "Check whether a single domain is available for registration and what it costs. Returns availability (`avail: yes|no`), registration price, renewal price, transfer price, and (for premium domains) extended pricing details. Pricing is in USD. Use this BEFORE register_domain to confirm cost — Porkbun rejects registrations whose `cost` doesn't match the current quote.",
14
16
  inputSchema: {
15
17
  domain: z
16
18
  .string()
17
19
  .min(3)
18
20
  .describe("Fully qualified domain name to check, e.g. `example.com`"),
19
21
  },
22
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
20
23
  handler: async (config, args) => {
21
24
  const domain = String(args.domain).toLowerCase();
22
- return await call(config, `/domain/checkSingleDomain/${encodeURIComponent(domain)}`, {
23
- method: "GET",
25
+ return await call(config, `/domain/checkDomain/${encodeURIComponent(domain)}`, {
26
+ method: "POST",
24
27
  });
25
28
  },
26
29
  };
@@ -39,6 +42,7 @@ const list_domains = {
39
42
  .optional()
40
43
  .describe("If true, include user-defined domain labels in the response."),
41
44
  },
45
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
42
46
  handler: async (config, args) => {
43
47
  const params = new URLSearchParams();
44
48
  if (args.start !== undefined)
@@ -53,6 +57,7 @@ const get_balance = {
53
57
  name: "get_balance",
54
58
  description: "Get the available account credit balance for the authenticated Porkbun account. Returns the balance in cents (integer) and a human-readable display string (e.g. `$12.34`). Use this to check spend headroom before initiating registrations or renewals.",
55
59
  inputSchema: {},
60
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
56
61
  handler: async (config) => {
57
62
  return await call(config, "/account/balance", { method: "GET" });
58
63
  },
@@ -61,6 +66,7 @@ const get_pricing = {
61
66
  name: "get_pricing",
62
67
  description: "Get current Porkbun pricing for all supported TLDs. Returns registration, renewal, and transfer prices per TLD in USD. No authentication required. Useful when an agent needs to compare TLD costs before registering. Note: this returns standard pricing only — premium domains have their own per-domain pricing reported by check_domain.",
63
68
  inputSchema: {},
69
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
64
70
  handler: async (config) => {
65
71
  return await call(config, "/pricing/get", { method: "POST" });
66
72
  },
@@ -74,6 +80,7 @@ const list_dns_records = {
74
80
  .min(3)
75
81
  .describe("Fully qualified domain name registered at Porkbun, e.g. `example.com`"),
76
82
  },
83
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
77
84
  handler: async (config, args) => {
78
85
  const domain = String(args.domain).toLowerCase();
79
86
  return await call(config, `/dns/retrieve/${encodeURIComponent(domain)}`, { method: "GET" });
@@ -88,12 +95,224 @@ const get_ssl_bundle = {
88
95
  .min(3)
89
96
  .describe("Fully qualified domain name registered at Porkbun, e.g. `example.com`"),
90
97
  },
98
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
91
99
  handler: async (config, args) => {
92
100
  const domain = String(args.domain).toLowerCase();
93
101
  return await call(config, `/ssl/retrieve/${encodeURIComponent(domain)}`, { method: "GET" });
94
102
  },
95
103
  };
104
+ // ─── Domain lifecycle (write — these spend account credit) ──────────────────
105
+ const DNS_RECORD_TYPES = [
106
+ "A",
107
+ "AAAA",
108
+ "CNAME",
109
+ "MX",
110
+ "TXT",
111
+ "NS",
112
+ "ALIAS",
113
+ "SRV",
114
+ "TLSA",
115
+ "CAA",
116
+ "HTTPS",
117
+ "SVCB",
118
+ ];
119
+ const register_domain = {
120
+ name: "register_domain",
121
+ description: "**Spends account credit.** Registers a new domain on the authenticated Porkbun account. The `cost` parameter must exactly match the current registration price returned by `check_domain` (in cents) — Porkbun rejects mismatched quotes. Workflow: call `check_domain` first to get availability + price, confirm the spend with the user, then call this. The order is idempotency-safe: retries within 24 hours via the same Idempotency-Key return the original response without re-charging. Premium domains, .uk, and a handful of registry-specific TLDs cannot be registered via API and must be done on the website. The account's email and phone number must be verified, and the account must have at least one prior registration order before this works.",
122
+ inputSchema: {
123
+ domain: z
124
+ .string()
125
+ .min(3)
126
+ .describe("Fully qualified domain name to register, e.g. `example.com`"),
127
+ cost: z
128
+ .number()
129
+ .int()
130
+ .positive()
131
+ .describe("Registration price in cents. Must match the value returned by `check_domain` for this domain (multiplied by years if duration > 1)."),
132
+ },
133
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
134
+ handler: async (config, args) => {
135
+ const domain = String(args.domain).toLowerCase();
136
+ return await call(config, `/domain/create/${encodeURIComponent(domain)}`, {
137
+ method: "POST",
138
+ idempotent: true,
139
+ body: {
140
+ cost: Number(args.cost),
141
+ agreeToTerms: "yes",
142
+ },
143
+ });
144
+ },
145
+ };
146
+ const renew_domain = {
147
+ name: "renew_domain",
148
+ description: "**Spends account credit.** Renews an existing domain in the authenticated account. The `cost` parameter must exactly match the current renewal price returned by `check_domain` (in cents). The domain must be opted in to API access (per-domain or global toggle in account settings). Domains registered within the last 30 days, or already renewed within the last 30 days, cannot be renewed yet — the API returns `RENEWAL_TOO_SOON`. Premium domain renewals are not supported via API. Idempotency-safe: retries within 24 hours don't double-charge.",
149
+ inputSchema: {
150
+ domain: z.string().min(3).describe("Domain name to renew, e.g. `example.com`. Must already be in your account."),
151
+ cost: z
152
+ .number()
153
+ .int()
154
+ .positive()
155
+ .describe("Renewal price in cents. Must match the value returned by `check_domain`."),
156
+ },
157
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
158
+ handler: async (config, args) => {
159
+ const domain = String(args.domain).toLowerCase();
160
+ return await call(config, `/domain/renew/${encodeURIComponent(domain)}`, {
161
+ method: "POST",
162
+ idempotent: true,
163
+ body: { cost: Number(args.cost) },
164
+ });
165
+ },
166
+ };
167
+ const transfer_domain = {
168
+ name: "transfer_domain",
169
+ description: "**Spends account credit.** Initiates a transfer of an external domain into Porkbun. Returns immediately with a `transferId`; the actual registry transfer takes 5-7 days for most TLDs. Use `get_transfer_status` to poll. Requires the auth/EPP code from the losing registrar. The `cost` must match the current transfer price from `check_domain`. .uk domains and a few TLDs do not support inbound API transfers. Idempotency-safe.",
170
+ inputSchema: {
171
+ domain: z.string().min(3).describe("Domain to transfer in, e.g. `example.com`"),
172
+ cost: z
173
+ .number()
174
+ .int()
175
+ .positive()
176
+ .describe("Transfer price in cents. Must match the value returned by `check_domain`."),
177
+ auth_code: z
178
+ .string()
179
+ .min(1)
180
+ .describe("Authorization (EPP) code from the losing registrar."),
181
+ },
182
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
183
+ handler: async (config, args) => {
184
+ const domain = String(args.domain).toLowerCase();
185
+ return await call(config, `/domain/transfer/${encodeURIComponent(domain)}`, {
186
+ method: "POST",
187
+ idempotent: true,
188
+ body: {
189
+ cost: Number(args.cost),
190
+ authCode: String(args.auth_code),
191
+ },
192
+ });
193
+ },
194
+ };
195
+ // ─── DNS writes ─────────────────────────────────────────────────────────────
196
+ const create_dns_record = {
197
+ name: "create_dns_record",
198
+ description: "Create a DNS record on a domain in the authenticated account. Returns the new record's `id` so it can be referenced by `update_dns_record` and `delete_dns_record`. For the `name` field: omit or pass empty string for the apex/root, otherwise pass the subdomain prefix only (e.g. `www`, not `www.example.com`). For MX and SRV records, set `prio` (priority). Free, doesn't spend account credit.",
199
+ inputSchema: {
200
+ domain: z.string().min(3).describe("Domain to add the record to, e.g. `example.com`"),
201
+ type: z
202
+ .enum(DNS_RECORD_TYPES)
203
+ .describe("Record type. Common: A, AAAA, CNAME, MX, TXT."),
204
+ content: z
205
+ .string()
206
+ .min(1)
207
+ .describe("Record value (e.g. an IP for A, a hostname for CNAME, the text body for TXT)."),
208
+ name: z
209
+ .string()
210
+ .optional()
211
+ .describe("Subdomain prefix (no domain). Empty string or omitted = apex. Examples: `www`, `mail`, `api.staging`."),
212
+ ttl: z
213
+ .number()
214
+ .int()
215
+ .min(60)
216
+ .optional()
217
+ .describe("Time-to-live in seconds. Minimum 60. Defaults to 600 if omitted."),
218
+ prio: z
219
+ .number()
220
+ .int()
221
+ .min(0)
222
+ .optional()
223
+ .describe("Priority — required for MX and SRV records, ignored otherwise."),
224
+ },
225
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
226
+ handler: async (config, args) => {
227
+ const domain = String(args.domain).toLowerCase();
228
+ const body = {
229
+ type: args.type,
230
+ content: args.content,
231
+ };
232
+ if (args.name !== undefined)
233
+ body.name = args.name;
234
+ if (args.ttl !== undefined)
235
+ body.ttl = String(args.ttl);
236
+ if (args.prio !== undefined)
237
+ body.prio = String(args.prio);
238
+ return await call(config, `/dns/create/${encodeURIComponent(domain)}`, {
239
+ method: "POST",
240
+ idempotent: true,
241
+ body,
242
+ });
243
+ },
244
+ };
245
+ const update_dns_record = {
246
+ name: "update_dns_record",
247
+ description: "Update an existing DNS record by its numeric `record_id` (obtained from `list_dns_records`). All fields except `record_id` and `domain` are optional — pass only the ones you want to change. Idempotent: applying the same update twice is a no-op.",
248
+ inputSchema: {
249
+ domain: z.string().min(3).describe("Domain the record belongs to, e.g. `example.com`"),
250
+ record_id: z
251
+ .string()
252
+ .min(1)
253
+ .describe("Numeric record ID (as a string). Get this from `list_dns_records`."),
254
+ type: z.enum(DNS_RECORD_TYPES).optional().describe("New record type (rarely changed)."),
255
+ content: z.string().min(1).optional().describe("New record value."),
256
+ name: z.string().optional().describe("New subdomain prefix (empty string = apex)."),
257
+ ttl: z.number().int().min(60).optional().describe("New TTL in seconds."),
258
+ prio: z.number().int().min(0).optional().describe("New priority (MX/SRV only)."),
259
+ },
260
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
261
+ handler: async (config, args) => {
262
+ const domain = String(args.domain).toLowerCase();
263
+ const recordId = String(args.record_id);
264
+ const body = {};
265
+ if (args.type !== undefined)
266
+ body.type = args.type;
267
+ if (args.content !== undefined)
268
+ body.content = args.content;
269
+ if (args.name !== undefined)
270
+ body.name = args.name;
271
+ if (args.ttl !== undefined)
272
+ body.ttl = String(args.ttl);
273
+ if (args.prio !== undefined)
274
+ body.prio = String(args.prio);
275
+ return await call(config, `/dns/edit/${encodeURIComponent(domain)}/${encodeURIComponent(recordId)}`, { method: "POST", idempotent: true, body });
276
+ },
277
+ };
278
+ const delete_dns_record = {
279
+ name: "delete_dns_record",
280
+ description: "Delete a single DNS record by its numeric `record_id` (obtained from `list_dns_records`). Idempotent: deleting an already-deleted record returns success. Free.",
281
+ inputSchema: {
282
+ domain: z.string().min(3).describe("Domain the record belongs to, e.g. `example.com`"),
283
+ record_id: z.string().min(1).describe("Numeric record ID (as a string)."),
284
+ },
285
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
286
+ handler: async (config, args) => {
287
+ const domain = String(args.domain).toLowerCase();
288
+ const recordId = String(args.record_id);
289
+ return await call(config, `/dns/delete/${encodeURIComponent(domain)}/${encodeURIComponent(recordId)}`, { method: "POST", idempotent: true });
290
+ },
291
+ };
292
+ // ─── Nameservers ────────────────────────────────────────────────────────────
293
+ const update_nameservers = {
294
+ name: "update_nameservers",
295
+ description: "Replace the nameservers for a domain in the authenticated account. **This is a full replacement, not an append** — the supplied list becomes the complete set of nameservers. Most TLDs require 2-13 entries. Setting custom nameservers disables Porkbun's free DNS hosting for the domain. Idempotent: applying the same NS list twice is a no-op.",
296
+ inputSchema: {
297
+ domain: z.string().min(3).describe("Domain to update, e.g. `example.com`"),
298
+ nameservers: z
299
+ .array(z.string().min(3))
300
+ .min(2)
301
+ .max(13)
302
+ .describe("Full list of nameservers (e.g. `['ns1.example.com', 'ns2.example.com']`). Minimum 2, maximum 13."),
303
+ },
304
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
305
+ handler: async (config, args) => {
306
+ const domain = String(args.domain).toLowerCase();
307
+ return await call(config, `/domain/updateNs/${encodeURIComponent(domain)}`, {
308
+ method: "POST",
309
+ idempotent: true,
310
+ body: { ns: args.nameservers },
311
+ });
312
+ },
313
+ };
96
314
  export const tools = [
315
+ // read
97
316
  ping,
98
317
  check_domain,
99
318
  list_domains,
@@ -101,4 +320,14 @@ export const tools = [
101
320
  get_pricing,
102
321
  list_dns_records,
103
322
  get_ssl_bundle,
323
+ // write — domain lifecycle
324
+ register_domain,
325
+ renew_domain,
326
+ transfer_domain,
327
+ // write — DNS
328
+ create_dns_record,
329
+ update_dns_record,
330
+ delete_dns_record,
331
+ // write — nameservers
332
+ update_nameservers,
104
333
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porkbunllc/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Porkbun MCP server — exposes the Porkbun v3 API as tools for AI agents (Claude Desktop, Cursor, etc.)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "build": "tsc",
16
16
  "start": "node dist/index.js",
17
17
  "dev": "tsc --watch",
18
+ "smoke": "node scripts/smoke.mjs",
18
19
  "prepublishOnly": "rm -rf dist && tsc"
19
20
  },
20
21
  "keywords": [